Skip to content

Commit 065ee0a

Browse files
authored
Merge pull request #256 from fairnesscoop/feat/add-user-savings-record
Add user savings record
2 parents 2108a72 + 93a3ced commit 065ee0a

File tree

16 files changed

+354
-5
lines changed

16 files changed

+354
-5
lines changed

README.md

+1
Original file line numberDiff line numberDiff line change
@@ -88,6 +88,7 @@ make test
8888
- Meal tickets
8989
- Leaves
9090
- Cooperators / employee
91+
- Savings records
9192
- Accounting
9293
- Quotations
9394
- Daily rates

client/i18n/fr.json

+11-2
Original file line numberDiff line numberDiff line change
@@ -251,8 +251,8 @@
251251
"human_resources": {
252252
"breadcrumb": "FairRH",
253253
"meal_tickets": {
254-
"breadcrumb": "Tickets Restaurant",
255-
"title": "Tickets Restaurant - {month}",
254+
"breadcrumb": "Tickets restaurant",
255+
"title": "Tickets restaurant - {month}",
256256
"user": "Coopérateur - salarié",
257257
"nb_meal_tickets": "Nb. ticket(s) restaurant",
258258
"nb_meal_tickets_removals": "Nb. exception(s)",
@@ -360,6 +360,15 @@
360360
"user_administrative_missing": "Veuillez saisir les informations administratives.",
361361
"not_found": "Adresse email ou mot de passe incorrect."
362362
}
363+
},
364+
"savings_records": {
365+
"title": "Épargne salariale",
366+
"add": {
367+
"title": "Ajouter une prime de participation"
368+
},
369+
"form": {
370+
"amount": "Montant"
371+
}
363372
}
364373
},
365374
"profile": {

client/src/components/Nav.svelte

+5
Original file line numberDiff line numberDiff line change
@@ -195,6 +195,11 @@
195195
class="w-full"
196196
href="human_resources/meal_tickets">{$_('human_resources.meal_tickets.breadcrumb')}</a>
197197
</li>
198+
<li class={subLinkClass}>
199+
<a
200+
class="w-full"
201+
href="human_resources/savings_records">{$_('human_resources.savings_records.title')}</a>
202+
</li>
198203
<li class={subLinkClass}>
199204
<a
200205
class="w-full"

client/src/routes/accounting/daily_rates/_Form.svelte

+1-1
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,7 @@
1616
const [customerResponse, taskResponse, userResponse] = await Promise.all([
1717
get('customers', { params: { page: 1 } }),
1818
get('tasks', { params: { page: 1 } }),
19-
get('users'),
19+
get('users', { params: {activeOnly: true} }),
2020
]);
2121
2222
users = userResponse.data;
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,38 @@
1+
<script>
2+
import { createEventDispatcher, onMount } from 'svelte';
3+
import { _ } from 'svelte-i18n';
4+
import { get } from 'utils/axios';
5+
import Button from 'components/inputs/Button.svelte';
6+
import Input from 'components/inputs/Input.svelte';
7+
import UsersInput from 'components/inputs/UsersInput.svelte';
8+
9+
const dispatch = createEventDispatcher();
10+
11+
export let userId;
12+
export let amount;
13+
export let loading;
14+
15+
let users = [];
16+
17+
onMount(async () => {
18+
users = (await get('users', { params: {activeOnly: true} })).data;
19+
});
20+
21+
const submit = () => {
22+
dispatch('save', { userId, amount });
23+
};
24+
</script>
25+
26+
<form
27+
on:submit|preventDefault={submit}
28+
class="px-4 py-3 mb-8 bg-white rounded-lg shadow-md dark:bg-gray-800">
29+
<UsersInput {users} bind:userId />
30+
<Input
31+
type={'money'}
32+
label={$_('human_resources.savings_records.form.amount')}
33+
bind:value={amount} />
34+
<Button
35+
value={$_('common.form.save')}
36+
{loading}
37+
disabled={!userId || !amount || loading} />
38+
</form>
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,44 @@
1+
<script>
2+
import { goto } from '@sapper/app';
3+
import { _ } from 'svelte-i18n';
4+
import Breadcrumb from 'components/Breadcrumb.svelte';
5+
import { post } from 'utils/axios';
6+
import { errorNormalizer } from 'normalizer/errors';
7+
import ServerErrors from 'components/ServerErrors.svelte';
8+
import H4Title from 'components/H4Title.svelte';
9+
import Form from './_Form.svelte';
10+
11+
const title = $_('human_resources.savings_records.add.title');
12+
let loading = false;
13+
let errors = [];
14+
15+
const onSave = async (e) => {
16+
try {
17+
loading = true;
18+
await post('users/savings-records/increase', {
19+
amount: e.detail.amount,
20+
userId: e.detail.userId,
21+
});
22+
goto('/human_resources/savings_records');
23+
} catch (e) {
24+
errors = errorNormalizer(e);
25+
} finally {
26+
loading = false;
27+
}
28+
};
29+
</script>
30+
31+
<svelte:head>
32+
<title>{title} - {$_('app')}</title>
33+
</svelte:head>
34+
35+
<Breadcrumb
36+
items={[
37+
{ title: $_('human_resources.breadcrumb') },
38+
{ title: $_('human_resources.savings_records.title'), path: '/human_resources/savings_records' },
39+
{ title }
40+
]}
41+
/>
42+
<H4Title {title} />
43+
<ServerErrors {errors} />
44+
<Form {loading} on:save={onSave} />
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
<script>
2+
import { _ } from 'svelte-i18n';
3+
import Breadcrumb from 'components/Breadcrumb.svelte';
4+
import H4Title from 'components/H4Title.svelte';
5+
import AddLink from 'components/links/AddLink.svelte';
6+
7+
const title = $_('human_resources.savings_records.title');
8+
</script>
9+
10+
<svelte:head>
11+
<title>{title} - {$_('app')}</title>
12+
</svelte:head>
13+
14+
<Breadcrumb items={[{ title: $_('human_resources.breadcrumb') }, { title }]} />
15+
<div class="inline-flex items-center">
16+
<H4Title {title} />
17+
<AddLink href={'/human_resources/savings_records/add'} value={$_('human_resources.savings_records.add.title')} />
18+
</div>
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
import { ICommand } from 'src/Application/ICommand';
2+
3+
export class IncreaseUserSavingsRecordCommand implements ICommand {
4+
constructor(
5+
public readonly amount: number,
6+
public readonly userId: string,
7+
) {}
8+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,74 @@
1+
import { mock, instance, when, verify, deepEqual, anything } from 'ts-mockito';
2+
import { User } from 'src/Domain/HumanResource/User/User.entity';
3+
import { IncreaseUserSavingsRecordCommandHandler } from './IncreaseUserSavingsRecordCommandHandler';
4+
import { IncreaseUserSavingsRecordCommand } from './IncreaseUserSavingsRecordCommand';
5+
import { SavingsRecordType, UserSavingsRecord } from 'src/Domain/HumanResource/Savings/UserSavingsRecord.entity';
6+
import { UserRepository } from 'src/Infrastructure/HumanResource/User/Repository/UserRepository';
7+
import { UserSavingsRecordRepository } from 'src/Infrastructure/HumanResource/Savings/Repository/UserSavingsRecordRepository';
8+
import { UserNotFoundException } from 'src/Domain/HumanResource/User/Exception/UserNotFoundException';
9+
10+
describe('IncreaseUserSavingsRecordCommandHandler', () => {
11+
let userRepository: UserRepository;
12+
let userSavingsRecordRepository: UserSavingsRecordRepository;
13+
let handler: IncreaseUserSavingsRecordCommandHandler;
14+
15+
const command = new IncreaseUserSavingsRecordCommand(
16+
5000,
17+
'a58c5253-c097-4f44-b8c1-ccd45aab36e3',
18+
);
19+
20+
beforeEach(() => {
21+
userRepository = mock(UserRepository);
22+
userSavingsRecordRepository = mock(UserSavingsRecordRepository);
23+
24+
handler = new IncreaseUserSavingsRecordCommandHandler(
25+
instance(userRepository),
26+
instance(userSavingsRecordRepository),
27+
);
28+
});
29+
30+
it('testUserNotFound', async () => {
31+
when(
32+
userRepository.findOneById('a58c5253-c097-4f44-b8c1-ccd45aab36e3')
33+
).thenResolve(null);
34+
35+
try {
36+
expect(await handler.execute(command)).toBeUndefined();
37+
} catch (e) {
38+
expect(e).toBeInstanceOf(UserNotFoundException);
39+
expect(e.message).toBe(
40+
'human_resources.users.errors.not_found'
41+
);
42+
verify(
43+
userRepository.findOneById('a58c5253-c097-4f44-b8c1-ccd45aab36e3')
44+
).once();
45+
verify(userSavingsRecordRepository.save(anything())).never();
46+
}
47+
});
48+
49+
it('testAddSuccessfully', async () => {
50+
const userSavingsRecord = mock(UserSavingsRecord);
51+
const user = mock(User);
52+
53+
when(userSavingsRecord.getId()).thenReturn('5c97487c-7863-46a2-967d-79eb8c94ecb5');
54+
when(
55+
userRepository.findOneById('a58c5253-c097-4f44-b8c1-ccd45aab36e3')
56+
).thenResolve(instance(user));
57+
when(
58+
userSavingsRecordRepository.save(
59+
deepEqual(new UserSavingsRecord(500000, SavingsRecordType.INPUT, instance(user)))
60+
)
61+
).thenResolve(instance(userSavingsRecord));
62+
63+
expect(await handler.execute(command)).toBe('5c97487c-7863-46a2-967d-79eb8c94ecb5');
64+
65+
verify(
66+
userRepository.findOneById('a58c5253-c097-4f44-b8c1-ccd45aab36e3')
67+
).once();
68+
verify(
69+
userSavingsRecordRepository.save(
70+
deepEqual(new UserSavingsRecord(500000, SavingsRecordType.INPUT, instance(user)))
71+
)
72+
).once();
73+
});
74+
});
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,36 @@
1+
import { CommandHandler } from '@nestjs/cqrs';
2+
import { Inject } from '@nestjs/common';
3+
import { IncreaseUserSavingsRecordCommand } from './IncreaseUserSavingsRecordCommand';
4+
import { IUserRepository } from 'src/Domain/HumanResource/User/Repository/IUserRepository';
5+
import { SavingsRecordType, UserSavingsRecord } from 'src/Domain/HumanResource/Savings/UserSavingsRecord.entity';
6+
import { UserNotFoundException } from 'src/Domain/HumanResource/User/Exception/UserNotFoundException';
7+
import { IUserSavingsRecordRepository } from 'src/Domain/HumanResource/Savings/Repository/IUserSavingsRecordRepository';
8+
9+
@CommandHandler(IncreaseUserSavingsRecordCommand)
10+
export class IncreaseUserSavingsRecordCommandHandler {
11+
constructor(
12+
@Inject('IUserRepository')
13+
private readonly userRepository: IUserRepository,
14+
@Inject('IUserSavingsRecordRepository')
15+
private readonly userSavingsRecordRepository: IUserSavingsRecordRepository,
16+
) {}
17+
18+
public async execute(command: IncreaseUserSavingsRecordCommand): Promise<string> {
19+
const { userId, amount } = command;
20+
21+
const user = await this.userRepository.findOneById(userId);
22+
if (!user) {
23+
throw new UserNotFoundException();
24+
}
25+
26+
const userSavingsRecord = await this.userSavingsRecordRepository.save(
27+
new UserSavingsRecord(
28+
Math.round(amount * 100),
29+
SavingsRecordType.INPUT,
30+
user,
31+
)
32+
);
33+
34+
return userSavingsRecord.getId();
35+
}
36+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
import { UserSavingsRecord } from '../UserSavingsRecord.entity';
2+
3+
export interface IUserSavingsRecordRepository {
4+
save(userSavingsRecord: UserSavingsRecord): Promise<UserSavingsRecord>;
5+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,44 @@
1+
import {
2+
Body,
3+
Post,
4+
Controller,
5+
Inject,
6+
BadRequestException,
7+
UseGuards
8+
} from '@nestjs/common';
9+
import { ApiTags, ApiBearerAuth, ApiOperation } from '@nestjs/swagger';
10+
import { AuthGuard } from '@nestjs/passport';
11+
import { ICommandBus } from 'src/Application/ICommandBus';
12+
import { UserRole } from 'src/Domain/HumanResource/User/User.entity';
13+
import { RolesGuard } from 'src/Infrastructure/HumanResource/User/Security/RolesGuard';
14+
import { Roles } from 'src/Infrastructure/HumanResource/User/Decorator/Roles';
15+
import { IncreaseUserSavingsRecordCommand } from 'src/Application/HumanResource/Savings/Command/IncreaseUserSavingsRecordCommand';
16+
import { UserSavingsRecordDTO } from '../DTO/UserSavingsRecordDTO';
17+
18+
@Controller('users/savings-records')
19+
@ApiTags('Human Resource')
20+
@ApiBearerAuth()
21+
@UseGuards(AuthGuard('bearer'), RolesGuard)
22+
export class IncreaseUserSavingsRecordAction {
23+
constructor(
24+
@Inject('ICommandBus')
25+
private readonly commandBus: ICommandBus
26+
) {}
27+
28+
@Post('increase')
29+
@Roles(UserRole.COOPERATOR, UserRole.EMPLOYEE)
30+
@ApiOperation({ summary: 'Increase user savings record' })
31+
public async index(
32+
@Body() { userId, amount }: UserSavingsRecordDTO,
33+
) {
34+
try {
35+
const id = await this.commandBus.execute(
36+
new IncreaseUserSavingsRecordCommand(amount, userId)
37+
);
38+
39+
return { id };
40+
} catch (e) {
41+
throw new BadRequestException(e.message);
42+
}
43+
}
44+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
import { UserSavingsRecordDTO } from './UserSavingsRecordDTO';
2+
import { validate } from 'class-validator';
3+
4+
describe('UserSavingsRecordDTO', () => {
5+
it('testValidDTO', async () => {
6+
const dto = new UserSavingsRecordDTO();
7+
dto.amount = -5000;
8+
dto.userId = 'e0884737-2a01-4f12-ac0e-c4d0ccc48d59';
9+
10+
const validation = await validate(dto);
11+
expect(validation).toHaveLength(0);
12+
});
13+
14+
it('testInvalidDTO', async () => {
15+
const dto = new UserSavingsRecordDTO();
16+
const validation = await validate(dto);
17+
18+
expect(validation).toHaveLength(2);
19+
expect(validation[0].constraints).toMatchObject({
20+
isNotEmpty: "userId should not be empty",
21+
isUuid: "userId must be an UUID"
22+
});
23+
expect(validation[1].constraints).toMatchObject({
24+
isNotEmpty: "amount should not be empty",
25+
isNumber: "amount must be a number conforming to the specified constraints"
26+
});
27+
});
28+
});
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
import { ApiProperty } from '@nestjs/swagger';
2+
import {
3+
IsNotEmpty,
4+
IsUUID,
5+
IsNumber,
6+
} from 'class-validator';
7+
8+
export class UserSavingsRecordDTO {
9+
@IsNotEmpty()
10+
@IsUUID()
11+
@ApiProperty()
12+
public userId: string;
13+
14+
@IsNotEmpty()
15+
@IsNumber()
16+
@ApiProperty()
17+
public amount: number;
18+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
import { InjectRepository } from '@nestjs/typeorm';
2+
import { Repository } from 'typeorm';
3+
import { IUserSavingsRecordRepository } from 'src/Domain/HumanResource/Savings/Repository/IUserSavingsRecordRepository';
4+
import { UserSavingsRecord } from 'src/Domain/HumanResource/Savings/UserSavingsRecord.entity';
5+
6+
export class UserSavingsRecordRepository implements IUserSavingsRecordRepository {
7+
constructor(
8+
@InjectRepository(UserSavingsRecord)
9+
private readonly repository: Repository<UserSavingsRecord>
10+
) {}
11+
12+
public save(userSavingsRecord: UserSavingsRecord): Promise<UserSavingsRecord> {
13+
return this.repository.save(userSavingsRecord);
14+
}
15+
}

0 commit comments

Comments
 (0)