diff --git a/src/shared/auth/role.guard.ts b/src/shared/auth/role.guard.ts index b0e3fe11af..e8bfe1d8b9 100644 --- a/src/shared/auth/role.guard.ts +++ b/src/shared/auth/role.guard.ts @@ -34,6 +34,7 @@ class RoleGuardClass implements CanActivate { [UserRole.COMPLIANCE]: [UserRole.ADMIN, UserRole.SUPER_ADMIN], [UserRole.BANKING_BOT]: [UserRole.ADMIN, UserRole.SUPER_ADMIN], [UserRole.REALUNIT]: [UserRole.ADMIN, UserRole.SUPER_ADMIN], + [UserRole.PARTNER]: [UserRole.ADMIN, UserRole.SUPER_ADMIN], [UserRole.ADMIN]: [UserRole.SUPER_ADMIN], [UserRole.DEBUG]: [UserRole.ADMIN, UserRole.SUPER_ADMIN], [UserRole.CLIENT_COMPANY]: [UserRole.KYC_CLIENT_COMPANY], diff --git a/src/shared/auth/user-role.enum.ts b/src/shared/auth/user-role.enum.ts index e30f9adbe2..1c79f0aba1 100644 --- a/src/shared/auth/user-role.enum.ts +++ b/src/shared/auth/user-role.enum.ts @@ -10,6 +10,7 @@ export enum UserRole { COMPLIANCE = 'Compliance', CUSTODY = 'Custody', REALUNIT = 'RealUnit', + PARTNER = 'Partner', MARKETING = 'Marketing', DEBUG = 'Debug', diff --git a/src/subdomains/generic/generic.module.ts b/src/subdomains/generic/generic.module.ts index 52b67c1aad..bc0f84f8af 100644 --- a/src/subdomains/generic/generic.module.ts +++ b/src/subdomains/generic/generic.module.ts @@ -3,11 +3,12 @@ import { AdminModule } from './admin/admin.module'; import { ForwardingModule } from './forwarding/forwarding.module'; import { GsModule } from './gs/gs.module'; import { KycModule } from './kyc/kyc.module'; +import { PartnerModule } from './partner/partner.module'; import { SupportModule } from './support/support.module'; import { UserModule } from './user/user.module'; @Module({ - imports: [AdminModule, UserModule, GsModule, SupportModule, KycModule, ForwardingModule], + imports: [AdminModule, UserModule, GsModule, SupportModule, KycModule, ForwardingModule, PartnerModule], controllers: [], providers: [], exports: [], diff --git a/src/subdomains/generic/partner/__tests__/partner.service.spec.ts b/src/subdomains/generic/partner/__tests__/partner.service.spec.ts new file mode 100644 index 0000000000..2dc340d0ca --- /dev/null +++ b/src/subdomains/generic/partner/__tests__/partner.service.spec.ts @@ -0,0 +1,184 @@ +import { createMock } from '@golevelup/ts-jest'; +import { ForbiddenException, NotFoundException } from '@nestjs/common'; +import { Test, TestingModule } from '@nestjs/testing'; +import * as ConfigModule from 'src/config/config'; +import { FeeService } from '../../../supporting/payment/services/fee.service'; +import { UserData } from '../../user/models/user-data/user-data.entity'; +import { UserDataStatus } from '../../user/models/user-data/user-data.enum'; +import { UserDataService } from '../../user/models/user-data/user-data.service'; +import { User } from '../../user/models/user/user.entity'; +import { UserService } from '../../user/models/user/user.service'; +import { PartnerService } from '../partner.service'; + +const CALLER_ID = 1; +const CALLER_REF = '158-532'; +const FEE_ID = 42; +const DEFAULT_REF = '000-000'; + +function makeUser(overrides: Partial = {}): User { + return Object.assign(new User(), { + id: 10, + usedRef: DEFAULT_REF, + userData: makeUserData(), + ...overrides, + }); +} + +function makeUserData(overrides: Partial = {}): UserData { + return Object.assign(new UserData(), { + id: 100, + status: UserDataStatus.NA, + individualFees: undefined, + users: [], + ...overrides, + }); +} + +describe('PartnerService', () => { + let service: PartnerService; + let userService: UserService; + let userDataService: UserDataService; + let feeService: FeeService; + + beforeAll(() => { + (ConfigModule as Record).Config = { defaultRef: DEFAULT_REF }; + }); + + beforeEach(async () => { + userService = createMock(); + userDataService = createMock(); + feeService = createMock(); + + const module: TestingModule = await Test.createTestingModule({ + providers: [ + PartnerService, + { provide: UserService, useValue: userService }, + { provide: UserDataService, useValue: userDataService }, + { provide: FeeService, useValue: feeService }, + ], + }).compile(); + + service = module.get(PartnerService); + }); + + describe('findUserByAddress', () => { + beforeEach(() => { + jest.spyOn(userService, 'getUser').mockResolvedValue(makeUser({ ref: CALLER_REF })); + }); + + it('returns dto when target.usedRef is defaultRef (new referee)', async () => { + const target = makeUser({ usedRef: DEFAULT_REF }); + jest.spyOn(userService, 'getUserByAddress').mockResolvedValue(target); + + const dto = await service.findUserByAddress('addr', CALLER_ID); + + expect(dto.canModify).toBe(true); + expect(dto.usedRef).toBe(DEFAULT_REF); + }); + + it('returns dto when target.usedRef equals caller.ref (existing referee)', async () => { + const target = makeUser({ usedRef: CALLER_REF }); + jest.spyOn(userService, 'getUserByAddress').mockResolvedValue(target); + + const dto = await service.findUserByAddress('addr', CALLER_ID); + + expect(dto.canModify).toBe(true); + expect(dto.usedRef).toBe(CALLER_REF); + }); + + it('throws ForbiddenException when target.usedRef is another partner', async () => { + const target = makeUser({ usedRef: '999-999' }); + jest.spyOn(userService, 'getUserByAddress').mockResolvedValue(target); + + await expect(service.findUserByAddress('addr', CALLER_ID)).rejects.toThrow(ForbiddenException); + }); + + it('throws NotFoundException when address has no user', async () => { + jest.spyOn(userService, 'getUserByAddress').mockResolvedValue(undefined); + + await expect(service.findUserByAddress('addr', CALLER_ID)).rejects.toThrow(NotFoundException); + }); + + it('throws ForbiddenException when caller has no ref', async () => { + jest.spyOn(userService, 'getUser').mockResolvedValue(makeUser({ ref: undefined })); + + await expect(service.findUserByAddress('addr', CALLER_ID)).rejects.toThrow(ForbiddenException); + }); + }); + + describe('setOnboarding', () => { + beforeEach(() => { + jest.spyOn(userService, 'getUser').mockResolvedValue(makeUser({ ref: CALLER_REF })); + }); + + it('sets fee + status + usedRef for in-scope userData', async () => { + const inScopeUser = makeUser({ id: 11, usedRef: DEFAULT_REF }); + const userData = makeUserData({ users: [inScopeUser] }); + jest.spyOn(userDataService, 'getUserData').mockResolvedValue(userData); + + await service.setOnboarding(userData.id, FEE_ID, CALLER_ID); + + expect(feeService.addFeeInternal).toHaveBeenCalledWith(userData, FEE_ID); + expect(userDataService.updateUserDataInternal).toHaveBeenCalledWith(userData, { + status: UserDataStatus.ACTIVE, + }); + expect(userService.updateUserAdmin).toHaveBeenCalledWith(inScopeUser.id, { usedRef: CALLER_REF }); + }); + + it('throws ForbiddenException when userData has out-of-scope user', async () => { + const outOfScopeUser = makeUser({ id: 12, usedRef: '999-999' }); + const userData = makeUserData({ users: [outOfScopeUser] }); + jest.spyOn(userDataService, 'getUserData').mockResolvedValue(userData); + + await expect(service.setOnboarding(userData.id, FEE_ID, CALLER_ID)).rejects.toThrow(ForbiddenException); + expect(feeService.addFeeInternal).not.toHaveBeenCalled(); + }); + + it('skips status update when userData already Active', async () => { + const userData = makeUserData({ + status: UserDataStatus.ACTIVE, + users: [makeUser({ usedRef: CALLER_REF })], + }); + jest.spyOn(userDataService, 'getUserData').mockResolvedValue(userData); + + await service.setOnboarding(userData.id, FEE_ID, CALLER_ID); + + expect(userDataService.updateUserDataInternal).not.toHaveBeenCalled(); + }); + + it('only updates usedRef for users that currently have defaultRef', async () => { + const newUser = makeUser({ id: 13, usedRef: DEFAULT_REF }); + const existingUser = makeUser({ id: 14, usedRef: CALLER_REF }); + const userData = makeUserData({ users: [newUser, existingUser] }); + jest.spyOn(userDataService, 'getUserData').mockResolvedValue(userData); + + await service.setOnboarding(userData.id, FEE_ID, CALLER_ID); + + expect(userService.updateUserAdmin).toHaveBeenCalledTimes(1); + expect(userService.updateUserAdmin).toHaveBeenCalledWith(newUser.id, { usedRef: CALLER_REF }); + }); + }); + + describe('removeFee', () => { + beforeEach(() => { + jest.spyOn(userService, 'getUser').mockResolvedValue(makeUser({ ref: CALLER_REF })); + }); + + it('removes fee for in-scope userData', async () => { + const userData = makeUserData({ users: [makeUser({ usedRef: CALLER_REF })] }); + jest.spyOn(userDataService, 'getUserData').mockResolvedValue(userData); + + await service.removeFee(userData.id, FEE_ID, CALLER_ID); + + expect(userDataService.removeFee).toHaveBeenCalledWith(userData, FEE_ID); + }); + + it('throws ForbiddenException when out-of-scope', async () => { + const userData = makeUserData({ users: [makeUser({ usedRef: '999-999' })] }); + jest.spyOn(userDataService, 'getUserData').mockResolvedValue(userData); + + await expect(service.removeFee(userData.id, FEE_ID, CALLER_ID)).rejects.toThrow(ForbiddenException); + expect(userDataService.removeFee).not.toHaveBeenCalled(); + }); + }); +}); diff --git a/src/subdomains/generic/partner/dto/partner-fee.dto.ts b/src/subdomains/generic/partner/dto/partner-fee.dto.ts new file mode 100644 index 0000000000..f7dc17ba91 --- /dev/null +++ b/src/subdomains/generic/partner/dto/partner-fee.dto.ts @@ -0,0 +1,19 @@ +import { ApiProperty } from '@nestjs/swagger'; +import { FeeType } from '../../../supporting/payment/entities/fee.entity'; + +export class PartnerFeeDto { + @ApiProperty() + id: number; + + @ApiProperty() + label: string; + + @ApiProperty({ enum: FeeType }) + type: FeeType; + + @ApiProperty() + rate: number; + + @ApiProperty() + fixed: number; +} diff --git a/src/subdomains/generic/partner/dto/partner-user-info.dto.ts b/src/subdomains/generic/partner/dto/partner-user-info.dto.ts new file mode 100644 index 0000000000..1aa23ff383 --- /dev/null +++ b/src/subdomains/generic/partner/dto/partner-user-info.dto.ts @@ -0,0 +1,28 @@ +import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; +import { UserDataStatus } from '../../user/models/user-data/user-data.enum'; + +export class PartnerUserInfoDto { + @ApiProperty() + id: number; + + @ApiProperty({ enum: UserDataStatus }) + status: UserDataStatus; + + @ApiPropertyOptional() + mail?: string; + + @ApiPropertyOptional() + firstname?: string; + + @ApiPropertyOptional() + surname?: string; + + @ApiProperty() + usedRef: string; + + @ApiProperty({ type: Number, isArray: true }) + feeIds: number[]; + + @ApiProperty() + canModify: boolean; +} diff --git a/src/subdomains/generic/partner/dto/set-onboarding-fee.dto.ts b/src/subdomains/generic/partner/dto/set-onboarding-fee.dto.ts new file mode 100644 index 0000000000..873e1c1d7e --- /dev/null +++ b/src/subdomains/generic/partner/dto/set-onboarding-fee.dto.ts @@ -0,0 +1,9 @@ +import { ApiProperty } from '@nestjs/swagger'; +import { IsInt, IsNotEmpty } from 'class-validator'; + +export class SetOnboardingFeeDto { + @ApiProperty() + @IsNotEmpty() + @IsInt() + feeId: number; +} diff --git a/src/subdomains/generic/partner/partner.controller.ts b/src/subdomains/generic/partner/partner.controller.ts new file mode 100644 index 0000000000..d15564718c --- /dev/null +++ b/src/subdomains/generic/partner/partner.controller.ts @@ -0,0 +1,57 @@ +import { Body, Controller, Delete, Get, Param, Put, Query, UseGuards } from '@nestjs/common'; +import { AuthGuard } from '@nestjs/passport'; +import { ApiBearerAuth, ApiExcludeController, ApiOkResponse, ApiTags } from '@nestjs/swagger'; +import { GetJwt } from 'src/shared/auth/get-jwt.decorator'; +import { JwtPayload } from 'src/shared/auth/jwt-payload.interface'; +import { RoleGuard } from 'src/shared/auth/role.guard'; +import { UserActiveGuard } from 'src/shared/auth/user-active.guard'; +import { UserRole } from 'src/shared/auth/user-role.enum'; +import { PartnerFeeDto } from './dto/partner-fee.dto'; +import { PartnerUserInfoDto } from './dto/partner-user-info.dto'; +import { SetOnboardingFeeDto } from './dto/set-onboarding-fee.dto'; +import { PartnerService } from './partner.service'; + +@ApiTags('Partner') +@Controller('partner') +@ApiExcludeController() +@ApiBearerAuth() +@UseGuards(AuthGuard(), RoleGuard(UserRole.PARTNER), UserActiveGuard()) +export class PartnerController { + constructor(private readonly partnerService: PartnerService) {} + + @Get('user') + @ApiOkResponse({ type: PartnerUserInfoDto }) + async findUserByAddress(@GetJwt() jwt: JwtPayload, @Query('address') address: string): Promise { + return this.partnerService.findUserByAddress(address, jwt.user); + } + + @Get('users') + @ApiOkResponse({ type: PartnerUserInfoDto, isArray: true }) + async getMyReferees(@GetJwt() jwt: JwtPayload): Promise { + return this.partnerService.getMyReferees(jwt.user); + } + + @Get('fees') + @ApiOkResponse({ type: PartnerFeeDto, isArray: true }) + async getAvailableFees(@GetJwt() jwt: JwtPayload): Promise { + return this.partnerService.getAvailableFees(jwt.user); + } + + @Put('user/:userDataId/onboarding') + async setOnboarding( + @GetJwt() jwt: JwtPayload, + @Param('userDataId') userDataId: string, + @Body() dto: SetOnboardingFeeDto, + ): Promise { + return this.partnerService.setOnboarding(+userDataId, dto.feeId, jwt.user); + } + + @Delete('user/:userDataId/fee') + async removeFee( + @GetJwt() jwt: JwtPayload, + @Param('userDataId') userDataId: string, + @Query('fee') feeId: string, + ): Promise { + return this.partnerService.removeFee(+userDataId, +feeId, jwt.user); + } +} diff --git a/src/subdomains/generic/partner/partner.module.ts b/src/subdomains/generic/partner/partner.module.ts new file mode 100644 index 0000000000..d77500e7ee --- /dev/null +++ b/src/subdomains/generic/partner/partner.module.ts @@ -0,0 +1,14 @@ +import { Module } from '@nestjs/common'; +import { SharedModule } from 'src/shared/shared.module'; +import { PaymentModule } from '../../supporting/payment/payment.module'; +import { UserModule } from '../user/user.module'; +import { PartnerController } from './partner.controller'; +import { PartnerService } from './partner.service'; + +@Module({ + imports: [SharedModule, UserModule, PaymentModule], + controllers: [PartnerController], + providers: [PartnerService], + exports: [], +}) +export class PartnerModule {} diff --git a/src/subdomains/generic/partner/partner.service.ts b/src/subdomains/generic/partner/partner.service.ts new file mode 100644 index 0000000000..c8a3051a86 --- /dev/null +++ b/src/subdomains/generic/partner/partner.service.ts @@ -0,0 +1,121 @@ +import { ForbiddenException, Injectable, NotFoundException } from '@nestjs/common'; +import { Config } from 'src/config/config'; +import { FeeService } from '../../supporting/payment/services/fee.service'; +import { UserData } from '../user/models/user-data/user-data.entity'; +import { UserDataStatus } from '../user/models/user-data/user-data.enum'; +import { UserDataService } from '../user/models/user-data/user-data.service'; +import { User } from '../user/models/user/user.entity'; +import { UserService } from '../user/models/user/user.service'; +import { PartnerFeeDto } from './dto/partner-fee.dto'; +import { PartnerUserInfoDto } from './dto/partner-user-info.dto'; + +@Injectable() +export class PartnerService { + constructor( + private readonly userService: UserService, + private readonly userDataService: UserDataService, + private readonly feeService: FeeService, + ) {} + + // --- QUERIES --- // + + async findUserByAddress(address: string, callerUserId: number): Promise { + const callerRef = await this.getCallerRef(callerUserId); + + const user = await this.userService.getUserByAddress(address, { userData: true }); + if (!user) throw new NotFoundException('User not found'); + + this.verifyUserInScope(user, callerRef); + + return this.toDto(user, callerRef); + } + + async getMyReferees(callerUserId: number): Promise { + const callerRef = await this.getCallerRef(callerUserId); + + const users = await this.userService.getUsersByUsedRef(callerRef); + + return users.map((u) => this.toDto(u, callerRef)); + } + + async getAvailableFees(callerUserId: number): Promise { + const caller = await this.userService.getUser(callerUserId, { wallet: true }); + if (!caller?.ref) throw new ForbiddenException('Partner has no ref code assigned'); + + const fees = await this.feeService.getCustomFeesForPartner(caller.ref, caller.wallet?.id); + + return fees.map((f) => ({ + id: f.id, + label: f.label, + type: f.type, + rate: f.rate, + fixed: f.fixed, + })); + } + + // --- MUTATIONS --- // + + async setOnboarding(userDataId: number, feeId: number, callerUserId: number): Promise { + const callerRef = await this.getCallerRef(callerUserId); + + const userData = await this.userDataService.getUserData(userDataId, { users: true }); + if (!userData) throw new NotFoundException('UserData not found'); + + this.verifyUserDataInScope(userData, callerRef); + + await this.feeService.addFeeInternal(userData, feeId); + + if (userData.status !== UserDataStatus.ACTIVE) { + await this.userDataService.updateUserDataInternal(userData, { status: UserDataStatus.ACTIVE }); + } + + for (const u of userData.users) { + if (u.usedRef === Config.defaultRef) { + await this.userService.updateUserAdmin(u.id, { usedRef: callerRef }); + } + } + } + + async removeFee(userDataId: number, feeId: number, callerUserId: number): Promise { + const callerRef = await this.getCallerRef(callerUserId); + + const userData = await this.userDataService.getUserData(userDataId, { users: true }); + if (!userData) throw new NotFoundException('UserData not found'); + + this.verifyUserDataInScope(userData, callerRef); + + await this.userDataService.removeFee(userData, feeId); + } + + // --- HELPERS --- // + + private async getCallerRef(callerUserId: number): Promise { + const caller = await this.userService.getUser(callerUserId); + if (!caller?.ref) throw new ForbiddenException('Partner has no ref code assigned'); + return caller.ref; + } + + private verifyUserInScope(user: User, callerRef: string): void { + if (user.usedRef !== Config.defaultRef && user.usedRef !== callerRef) + throw new ForbiddenException('User is not in your referral scope'); + } + + private verifyUserDataInScope(userData: UserData, callerRef: string): void { + const hasOutOfScopeUser = userData.users.some((u) => u.usedRef !== Config.defaultRef && u.usedRef !== callerRef); + if (hasOutOfScopeUser) throw new ForbiddenException('UserData is not in your referral scope'); + } + + private toDto(user: User, callerRef: string): PartnerUserInfoDto { + const ud = user.userData; + return { + id: ud.id, + status: ud.status, + mail: ud.mail, + firstname: ud.firstname, + surname: ud.surname, + usedRef: user.usedRef, + feeIds: ud.individualFeeList ?? [], + canModify: user.usedRef === Config.defaultRef || user.usedRef === callerRef, + }; + } +} diff --git a/src/subdomains/generic/user/models/user/user.service.ts b/src/subdomains/generic/user/models/user/user.service.ts index 59f313cfe5..f645ca44ef 100644 --- a/src/subdomains/generic/user/models/user/user.service.ts +++ b/src/subdomains/generic/user/models/user/user.service.ts @@ -180,6 +180,10 @@ export class UserService { return this.userRepo.find({ where: { ref: In(refs) }, relations: { userData: true } }); } + async getUsersByUsedRef(usedRef: string): Promise { + return this.userRepo.find({ where: { usedRef }, relations: { userData: true } }); + } + async getNexCustodyIndex(): Promise { const currentIndex = await this.userRepo.maximum('custodyAddressIndex'); return (currentIndex ?? -1) + 1; diff --git a/src/subdomains/supporting/payment/services/fee.service.ts b/src/subdomains/supporting/payment/services/fee.service.ts index b272be72b2..39be15ac00 100644 --- a/src/subdomains/supporting/payment/services/fee.service.ts +++ b/src/subdomains/supporting/payment/services/fee.service.ts @@ -227,6 +227,12 @@ export class FeeService { return fee; } + async getCustomFeesForPartner(ref?: string, walletId?: number): Promise { + const ids = await this.settingService.getCustomSignUpFees(ref, walletId); + if (!ids.length) return []; + return this.getAllFees().then((fees) => fees.filter((f) => ids.includes(f.id) && f.active)); + } + async getChargebackFee(request: OptionalFeeRequest): Promise { const userFees = await this.getValidFees(request);