Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
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
1 change: 1 addition & 0 deletions src/shared/auth/role.guard.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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],
Expand Down
1 change: 1 addition & 0 deletions src/shared/auth/user-role.enum.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ export enum UserRole {
COMPLIANCE = 'Compliance',
CUSTODY = 'Custody',
REALUNIT = 'RealUnit',
PARTNER = 'Partner',
MARKETING = 'Marketing',
DEBUG = 'Debug',

Expand Down
3 changes: 2 additions & 1 deletion src/subdomains/generic/generic.module.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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: [],
Expand Down
184 changes: 184 additions & 0 deletions src/subdomains/generic/partner/__tests__/partner.service.spec.ts
Original file line number Diff line number Diff line change
@@ -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> = {}): User {
return Object.assign(new User(), {
id: 10,
usedRef: DEFAULT_REF,
userData: makeUserData(),
...overrides,
});
}

function makeUserData(overrides: Partial<UserData> = {}): 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<string, unknown>).Config = { defaultRef: DEFAULT_REF };
});

beforeEach(async () => {
userService = createMock<UserService>();
userDataService = createMock<UserDataService>();
feeService = createMock<FeeService>();

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>(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();
});
});
});
19 changes: 19 additions & 0 deletions src/subdomains/generic/partner/dto/partner-fee.dto.ts
Original file line number Diff line number Diff line change
@@ -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;
}
28 changes: 28 additions & 0 deletions src/subdomains/generic/partner/dto/partner-user-info.dto.ts
Original file line number Diff line number Diff line change
@@ -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;
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
import { ApiProperty } from '@nestjs/swagger';
import { IsInt, IsNotEmpty } from 'class-validator';

export class SetOnboardingFeeDto {
@ApiProperty()
@IsNotEmpty()
@IsInt()
feeId: number;
}
57 changes: 57 additions & 0 deletions src/subdomains/generic/partner/partner.controller.ts
Original file line number Diff line number Diff line change
@@ -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<PartnerUserInfoDto> {
return this.partnerService.findUserByAddress(address, jwt.user);
}

@Get('users')
@ApiOkResponse({ type: PartnerUserInfoDto, isArray: true })
async getMyReferees(@GetJwt() jwt: JwtPayload): Promise<PartnerUserInfoDto[]> {
return this.partnerService.getMyReferees(jwt.user);
}

@Get('fees')
@ApiOkResponse({ type: PartnerFeeDto, isArray: true })
async getAvailableFees(@GetJwt() jwt: JwtPayload): Promise<PartnerFeeDto[]> {
return this.partnerService.getAvailableFees(jwt.user);
}

@Put('user/:userDataId/onboarding')
async setOnboarding(
@GetJwt() jwt: JwtPayload,
@Param('userDataId') userDataId: string,
@Body() dto: SetOnboardingFeeDto,
): Promise<void> {
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<void> {
return this.partnerService.removeFee(+userDataId, +feeId, jwt.user);
}
}
14 changes: 14 additions & 0 deletions src/subdomains/generic/partner/partner.module.ts
Original file line number Diff line number Diff line change
@@ -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 {}
Loading