Skip to content

Commit

Permalink
Merge pull request #256 from fairnesscoop/feat/add-user-savings-record
Browse files Browse the repository at this point in the history
Add user savings record
  • Loading branch information
mmarchois authored Apr 30, 2022
2 parents 2108a72 + 93a3ced commit 065ee0a
Show file tree
Hide file tree
Showing 16 changed files with 354 additions and 5 deletions.
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -88,6 +88,7 @@ make test
- Meal tickets
- Leaves
- Cooperators / employee
- Savings records
- Accounting
- Quotations
- Daily rates
Expand Down
13 changes: 11 additions & 2 deletions client/i18n/fr.json
Original file line number Diff line number Diff line change
Expand Up @@ -251,8 +251,8 @@
"human_resources": {
"breadcrumb": "FairRH",
"meal_tickets": {
"breadcrumb": "Tickets Restaurant",
"title": "Tickets Restaurant - {month}",
"breadcrumb": "Tickets restaurant",
"title": "Tickets restaurant - {month}",
"user": "Coopérateur - salarié",
"nb_meal_tickets": "Nb. ticket(s) restaurant",
"nb_meal_tickets_removals": "Nb. exception(s)",
Expand Down Expand Up @@ -360,6 +360,15 @@
"user_administrative_missing": "Veuillez saisir les informations administratives.",
"not_found": "Adresse email ou mot de passe incorrect."
}
},
"savings_records": {
"title": "Épargne salariale",
"add": {
"title": "Ajouter une prime de participation"
},
"form": {
"amount": "Montant"
}
}
},
"profile": {
Expand Down
5 changes: 5 additions & 0 deletions client/src/components/Nav.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -195,6 +195,11 @@
class="w-full"
href="human_resources/meal_tickets">{$_('human_resources.meal_tickets.breadcrumb')}</a>
</li>
<li class={subLinkClass}>
<a
class="w-full"
href="human_resources/savings_records">{$_('human_resources.savings_records.title')}</a>
</li>
<li class={subLinkClass}>
<a
class="w-full"
Expand Down
2 changes: 1 addition & 1 deletion client/src/routes/accounting/daily_rates/_Form.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@
const [customerResponse, taskResponse, userResponse] = await Promise.all([
get('customers', { params: { page: 1 } }),
get('tasks', { params: { page: 1 } }),
get('users'),
get('users', { params: {activeOnly: true} }),
]);
users = userResponse.data;
Expand Down
38 changes: 38 additions & 0 deletions client/src/routes/human_resources/savings_records/_Form.svelte
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
<script>
import { createEventDispatcher, onMount } from 'svelte';
import { _ } from 'svelte-i18n';
import { get } from 'utils/axios';
import Button from 'components/inputs/Button.svelte';
import Input from 'components/inputs/Input.svelte';
import UsersInput from 'components/inputs/UsersInput.svelte';
const dispatch = createEventDispatcher();
export let userId;
export let amount;
export let loading;
let users = [];
onMount(async () => {
users = (await get('users', { params: {activeOnly: true} })).data;
});
const submit = () => {
dispatch('save', { userId, amount });
};
</script>

<form
on:submit|preventDefault={submit}
class="px-4 py-3 mb-8 bg-white rounded-lg shadow-md dark:bg-gray-800">
<UsersInput {users} bind:userId />
<Input
type={'money'}
label={$_('human_resources.savings_records.form.amount')}
bind:value={amount} />
<Button
value={$_('common.form.save')}
{loading}
disabled={!userId || !amount || loading} />
</form>
44 changes: 44 additions & 0 deletions client/src/routes/human_resources/savings_records/add.svelte
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
<script>
import { goto } from '@sapper/app';
import { _ } from 'svelte-i18n';
import Breadcrumb from 'components/Breadcrumb.svelte';
import { post } from 'utils/axios';
import { errorNormalizer } from 'normalizer/errors';
import ServerErrors from 'components/ServerErrors.svelte';
import H4Title from 'components/H4Title.svelte';
import Form from './_Form.svelte';
const title = $_('human_resources.savings_records.add.title');
let loading = false;
let errors = [];
const onSave = async (e) => {
try {
loading = true;
await post('users/savings-records/increase', {
amount: e.detail.amount,
userId: e.detail.userId,
});
goto('/human_resources/savings_records');
} catch (e) {
errors = errorNormalizer(e);
} finally {
loading = false;
}
};
</script>

<svelte:head>
<title>{title} - {$_('app')}</title>
</svelte:head>

<Breadcrumb
items={[
{ title: $_('human_resources.breadcrumb') },
{ title: $_('human_resources.savings_records.title'), path: '/human_resources/savings_records' },
{ title }
]}
/>
<H4Title {title} />
<ServerErrors {errors} />
<Form {loading} on:save={onSave} />
18 changes: 18 additions & 0 deletions client/src/routes/human_resources/savings_records/index.svelte
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
<script>
import { _ } from 'svelte-i18n';
import Breadcrumb from 'components/Breadcrumb.svelte';
import H4Title from 'components/H4Title.svelte';
import AddLink from 'components/links/AddLink.svelte';
const title = $_('human_resources.savings_records.title');
</script>

<svelte:head>
<title>{title} - {$_('app')}</title>
</svelte:head>

<Breadcrumb items={[{ title: $_('human_resources.breadcrumb') }, { title }]} />
<div class="inline-flex items-center">
<H4Title {title} />
<AddLink href={'/human_resources/savings_records/add'} value={$_('human_resources.savings_records.add.title')} />
</div>
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
import { ICommand } from 'src/Application/ICommand';

export class IncreaseUserSavingsRecordCommand implements ICommand {
constructor(
public readonly amount: number,
public readonly userId: string,
) {}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,74 @@
import { mock, instance, when, verify, deepEqual, anything } from 'ts-mockito';
import { User } from 'src/Domain/HumanResource/User/User.entity';
import { IncreaseUserSavingsRecordCommandHandler } from './IncreaseUserSavingsRecordCommandHandler';
import { IncreaseUserSavingsRecordCommand } from './IncreaseUserSavingsRecordCommand';
import { SavingsRecordType, UserSavingsRecord } from 'src/Domain/HumanResource/Savings/UserSavingsRecord.entity';
import { UserRepository } from 'src/Infrastructure/HumanResource/User/Repository/UserRepository';
import { UserSavingsRecordRepository } from 'src/Infrastructure/HumanResource/Savings/Repository/UserSavingsRecordRepository';
import { UserNotFoundException } from 'src/Domain/HumanResource/User/Exception/UserNotFoundException';

describe('IncreaseUserSavingsRecordCommandHandler', () => {
let userRepository: UserRepository;
let userSavingsRecordRepository: UserSavingsRecordRepository;
let handler: IncreaseUserSavingsRecordCommandHandler;

const command = new IncreaseUserSavingsRecordCommand(
5000,
'a58c5253-c097-4f44-b8c1-ccd45aab36e3',
);

beforeEach(() => {
userRepository = mock(UserRepository);
userSavingsRecordRepository = mock(UserSavingsRecordRepository);

handler = new IncreaseUserSavingsRecordCommandHandler(
instance(userRepository),
instance(userSavingsRecordRepository),
);
});

it('testUserNotFound', async () => {
when(
userRepository.findOneById('a58c5253-c097-4f44-b8c1-ccd45aab36e3')
).thenResolve(null);

try {
expect(await handler.execute(command)).toBeUndefined();
} catch (e) {
expect(e).toBeInstanceOf(UserNotFoundException);
expect(e.message).toBe(
'human_resources.users.errors.not_found'
);
verify(
userRepository.findOneById('a58c5253-c097-4f44-b8c1-ccd45aab36e3')
).once();
verify(userSavingsRecordRepository.save(anything())).never();
}
});

it('testAddSuccessfully', async () => {
const userSavingsRecord = mock(UserSavingsRecord);
const user = mock(User);

when(userSavingsRecord.getId()).thenReturn('5c97487c-7863-46a2-967d-79eb8c94ecb5');
when(
userRepository.findOneById('a58c5253-c097-4f44-b8c1-ccd45aab36e3')
).thenResolve(instance(user));
when(
userSavingsRecordRepository.save(
deepEqual(new UserSavingsRecord(500000, SavingsRecordType.INPUT, instance(user)))
)
).thenResolve(instance(userSavingsRecord));

expect(await handler.execute(command)).toBe('5c97487c-7863-46a2-967d-79eb8c94ecb5');

verify(
userRepository.findOneById('a58c5253-c097-4f44-b8c1-ccd45aab36e3')
).once();
verify(
userSavingsRecordRepository.save(
deepEqual(new UserSavingsRecord(500000, SavingsRecordType.INPUT, instance(user)))
)
).once();
});
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
import { CommandHandler } from '@nestjs/cqrs';
import { Inject } from '@nestjs/common';
import { IncreaseUserSavingsRecordCommand } from './IncreaseUserSavingsRecordCommand';
import { IUserRepository } from 'src/Domain/HumanResource/User/Repository/IUserRepository';
import { SavingsRecordType, UserSavingsRecord } from 'src/Domain/HumanResource/Savings/UserSavingsRecord.entity';
import { UserNotFoundException } from 'src/Domain/HumanResource/User/Exception/UserNotFoundException';
import { IUserSavingsRecordRepository } from 'src/Domain/HumanResource/Savings/Repository/IUserSavingsRecordRepository';

@CommandHandler(IncreaseUserSavingsRecordCommand)
export class IncreaseUserSavingsRecordCommandHandler {
constructor(
@Inject('IUserRepository')
private readonly userRepository: IUserRepository,
@Inject('IUserSavingsRecordRepository')
private readonly userSavingsRecordRepository: IUserSavingsRecordRepository,
) {}

public async execute(command: IncreaseUserSavingsRecordCommand): Promise<string> {
const { userId, amount } = command;

const user = await this.userRepository.findOneById(userId);
if (!user) {
throw new UserNotFoundException();
}

const userSavingsRecord = await this.userSavingsRecordRepository.save(
new UserSavingsRecord(
Math.round(amount * 100),
SavingsRecordType.INPUT,
user,
)
);

return userSavingsRecord.getId();
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
import { UserSavingsRecord } from '../UserSavingsRecord.entity';

export interface IUserSavingsRecordRepository {
save(userSavingsRecord: UserSavingsRecord): Promise<UserSavingsRecord>;
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
import {
Body,
Post,
Controller,
Inject,
BadRequestException,
UseGuards
} from '@nestjs/common';
import { ApiTags, ApiBearerAuth, ApiOperation } from '@nestjs/swagger';
import { AuthGuard } from '@nestjs/passport';
import { ICommandBus } from 'src/Application/ICommandBus';
import { UserRole } from 'src/Domain/HumanResource/User/User.entity';
import { RolesGuard } from 'src/Infrastructure/HumanResource/User/Security/RolesGuard';
import { Roles } from 'src/Infrastructure/HumanResource/User/Decorator/Roles';
import { IncreaseUserSavingsRecordCommand } from 'src/Application/HumanResource/Savings/Command/IncreaseUserSavingsRecordCommand';
import { UserSavingsRecordDTO } from '../DTO/UserSavingsRecordDTO';

@Controller('users/savings-records')
@ApiTags('Human Resource')
@ApiBearerAuth()
@UseGuards(AuthGuard('bearer'), RolesGuard)
export class IncreaseUserSavingsRecordAction {
constructor(
@Inject('ICommandBus')
private readonly commandBus: ICommandBus
) {}

@Post('increase')
@Roles(UserRole.COOPERATOR, UserRole.EMPLOYEE)
@ApiOperation({ summary: 'Increase user savings record' })
public async index(
@Body() { userId, amount }: UserSavingsRecordDTO,
) {
try {
const id = await this.commandBus.execute(
new IncreaseUserSavingsRecordCommand(amount, userId)
);

return { id };
} catch (e) {
throw new BadRequestException(e.message);
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
import { UserSavingsRecordDTO } from './UserSavingsRecordDTO';
import { validate } from 'class-validator';

describe('UserSavingsRecordDTO', () => {
it('testValidDTO', async () => {
const dto = new UserSavingsRecordDTO();
dto.amount = -5000;
dto.userId = 'e0884737-2a01-4f12-ac0e-c4d0ccc48d59';

const validation = await validate(dto);
expect(validation).toHaveLength(0);
});

it('testInvalidDTO', async () => {
const dto = new UserSavingsRecordDTO();
const validation = await validate(dto);

expect(validation).toHaveLength(2);
expect(validation[0].constraints).toMatchObject({
isNotEmpty: "userId should not be empty",
isUuid: "userId must be an UUID"
});
expect(validation[1].constraints).toMatchObject({
isNotEmpty: "amount should not be empty",
isNumber: "amount must be a number conforming to the specified constraints"
});
});
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
import { ApiProperty } from '@nestjs/swagger';
import {
IsNotEmpty,
IsUUID,
IsNumber,
} from 'class-validator';

export class UserSavingsRecordDTO {
@IsNotEmpty()
@IsUUID()
@ApiProperty()
public userId: string;

@IsNotEmpty()
@IsNumber()
@ApiProperty()
public amount: number;
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import { IUserSavingsRecordRepository } from 'src/Domain/HumanResource/Savings/Repository/IUserSavingsRecordRepository';
import { UserSavingsRecord } from 'src/Domain/HumanResource/Savings/UserSavingsRecord.entity';

export class UserSavingsRecordRepository implements IUserSavingsRecordRepository {
constructor(
@InjectRepository(UserSavingsRecord)
private readonly repository: Repository<UserSavingsRecord>
) {}

public save(userSavingsRecord: UserSavingsRecord): Promise<UserSavingsRecord> {
return this.repository.save(userSavingsRecord);
}
}
Loading

0 comments on commit 065ee0a

Please sign in to comment.