Skip to content
Projects
Groups
Snippets
Help
Loading...
Help
Submit feedback
Contribute to GitLab
Sign in / Register
Toggle navigation
H
hrsystem
Project
Project
Details
Activity
Releases
Cycle Analytics
Repository
Repository
Files
Commits
Branches
Tags
Contributors
Graph
Compare
Charts
Issues
0
Issues
0
List
Board
Labels
Milestones
Merge Requests
0
Merge Requests
0
CI / CD
CI / CD
Pipelines
Jobs
Schedules
Charts
Wiki
Wiki
Snippets
Snippets
Members
Members
Collapse sidebar
Close sidebar
Activity
Graph
Charts
Create a new issue
Jobs
Commits
Issue Boards
Open sidebar
Administrator
hrsystem
Commits
fcbd2e22
Commit
fcbd2e22
authored
Apr 01, 2026
by
Administrator
Browse files
Options
Browse Files
Download
Email Patches
Plain Diff
Update 30 files via Son of Anton
parent
56d9d3ef
Changes
30
Hide whitespace changes
Inline
Side-by-side
Showing
30 changed files
with
2957 additions
and
0 deletions
+2957
-0
app.module.ts
backend/src/app.module.ts
+15
-0
adjustments.controller.ts
backend/src/modules/adjustments/adjustments.controller.ts
+62
-0
adjustments.module.ts
backend/src/modules/adjustments/adjustments.module.ts
+12
-0
adjustments.service.ts
backend/src/modules/adjustments/adjustments.service.ts
+233
-0
adjustment-filter.dto.ts
backend/src/modules/adjustments/dto/adjustment-filter.dto.ts
+20
-0
create-adjustment.dto.ts
backend/src/modules/adjustments/dto/create-adjustment.dto.ts
+28
-0
bounties.controller.ts
backend/src/modules/bounties/bounties.controller.ts
+42
-0
bounties.module.ts
backend/src/modules/bounties/bounties.module.ts
+12
-0
bounties.service.ts
backend/src/modules/bounties/bounties.service.ts
+201
-0
card-movement.service.ts
backend/src/modules/cards/card-movement.service.ts
+66
-0
deduction-calculator.service.ts
...nd/src/modules/deductions/deduction-calculator.service.ts
+98
-0
deduction-preset.service.ts
backend/src/modules/deductions/deduction-preset.service.ts
+79
-0
deductions.controller.ts
backend/src/modules/deductions/deductions.controller.ts
+126
-0
deductions.module.ts
backend/src/modules/deductions/deductions.module.ts
+15
-0
deductions.service.ts
backend/src/modules/deductions/deductions.service.ts
+516
-0
create-deduction.dto.ts
backend/src/modules/deductions/dto/create-deduction.dto.ts
+39
-0
deduction-filter.dto.ts
backend/src/modules/deductions/dto/deduction-filter.dto.ts
+32
-0
respond-deduction.dto.ts
backend/src/modules/deductions/dto/respond-deduction.dto.ts
+14
-0
review-deduction.dto.ts
backend/src/modules/deductions/dto/review-deduction.dto.ts
+15
-0
hud.gateway.ts
backend/src/modules/hud/hud.gateway.ts
+121
-0
hud.module.ts
backend/src/modules/hud/hud.module.ts
+13
-0
hud.service.ts
backend/src/modules/hud/hud.service.ts
+89
-0
payroll-filter.dto.ts
backend/src/modules/payroll/dto/payroll-filter.dto.ts
+19
-0
payroll.controller.ts
backend/src/modules/payroll/payroll.controller.ts
+89
-0
payroll.module.ts
backend/src/modules/payroll/payroll.module.ts
+12
-0
payroll.service.ts
backend/src/modules/payroll/payroll.service.ts
+375
-0
salary.controller.ts
backend/src/modules/salary/salary.controller.ts
+71
-0
salary.module.ts
backend/src/modules/salary/salary.module.ts
+10
-0
salary.service.ts
backend/src/modules/salary/salary.service.ts
+283
-0
schema-financial.prisma
prisma/schema-financial.prisma
+250
-0
No files found.
backend/src/app.module.ts
View file @
fcbd2e22
...
...
@@ -25,6 +25,14 @@ import { CommentsModule } from './modules/comments/comments.module';
import
{
ChecklistsModule
}
from
'./modules/checklists/checklists.module'
;
import
{
AttachmentsModule
}
from
'./modules/attachments/attachments.module'
;
// ─── Phase 1D: Financial Core ───────────────────────────────
import
{
SalaryModule
}
from
'./modules/salary/salary.module'
;
import
{
HudModule
}
from
'./modules/hud/hud.module'
;
import
{
DeductionsModule
}
from
'./modules/deductions/deductions.module'
;
import
{
BountiesModule
}
from
'./modules/bounties/bounties.module'
;
import
{
AdjustmentsModule
}
from
'./modules/adjustments/adjustments.module'
;
import
{
PayrollModule
}
from
'./modules/payroll/payroll.module'
;
import
{
JwtAuthGuard
}
from
'./common/guards/jwt-auth.guard'
;
import
{
RolesGuard
}
from
'./common/guards/roles.guard'
;
import
{
TransformInterceptor
}
from
'./common/interceptors/transform.interceptor'
;
...
...
@@ -54,6 +62,13 @@ import { RateLimitMiddleware } from './common/middleware/rate-limit.middleware';
CommentsModule
,
ChecklistsModule
,
AttachmentsModule
,
// Phase 1D
SalaryModule
,
HudModule
,
DeductionsModule
,
BountiesModule
,
AdjustmentsModule
,
PayrollModule
,
],
providers
:
[
{
provide
:
APP_GUARD
,
useClass
:
JwtAuthGuard
},
...
...
backend/src/modules/adjustments/adjustments.controller.ts
0 → 100644
View file @
fcbd2e22
import
{
Controller
,
Get
,
Post
,
Put
,
Delete
,
Body
,
Param
,
Query
,
HttpCode
,
HttpStatus
,
}
from
'@nestjs/common'
;
import
{
AdjustmentsService
}
from
'./adjustments.service'
;
import
{
CreateAdjustmentDto
}
from
'./dto/create-adjustment.dto'
;
import
{
AdjustmentFilterDto
}
from
'./dto/adjustment-filter.dto'
;
import
{
CurrentUser
,
RequestUser
}
from
'../../common/decorators/current-user.decorator'
;
import
{
Roles
}
from
'../../common/decorators/roles.decorator'
;
@
Controller
(
'adjustments'
)
export
class
AdjustmentsController
{
constructor
(
private
readonly
adjustmentsService
:
AdjustmentsService
)
{}
@
Post
()
@
Roles
(
'SUPER_ADMIN'
,
'ADMIN'
)
async
create
(@
Body
()
dto
:
CreateAdjustmentDto
,
@
CurrentUser
()
user
:
RequestUser
)
{
return
this
.
adjustmentsService
.
create
(
dto
,
user
);
}
@
Get
()
async
findAll
(@
Query
()
filter
:
AdjustmentFilterDto
,
@
CurrentUser
()
user
:
RequestUser
)
{
return
this
.
adjustmentsService
.
findAll
(
filter
,
user
);
}
@
Get
(
':id'
)
async
findById
(@
Param
(
'id'
)
id
:
string
,
@
CurrentUser
()
user
:
RequestUser
)
{
return
this
.
adjustmentsService
.
findById
(
id
,
user
);
}
@
Put
(
':id/review'
)
@
Roles
(
'SUPER_ADMIN'
)
async
review
(
@
Param
(
'id'
)
id
:
string
,
@
Body
()
body
:
{
decision
:
'APPROVED'
|
'REJECTED'
;
reason
?:
string
},
@
CurrentUser
()
user
:
RequestUser
,
)
{
return
this
.
adjustmentsService
.
review
(
id
,
body
.
decision
,
body
.
reason
,
user
);
}
@
Put
(
':id'
)
@
Roles
(
'SUPER_ADMIN'
)
async
update
(@
Param
(
'id'
)
id
:
string
,
@
Body
()
data
:
any
,
@
CurrentUser
()
user
:
RequestUser
)
{
return
this
.
adjustmentsService
.
update
(
id
,
data
,
user
);
}
@
Delete
(
':id'
)
@
Roles
(
'SUPER_ADMIN'
)
@
HttpCode
(
HttpStatus
.
OK
)
async
delete
(@
Param
(
'id'
)
id
:
string
,
@
CurrentUser
()
user
:
RequestUser
)
{
await
this
.
adjustmentsService
.
delete
(
id
,
user
);
return
{
message
:
'Adjustment deleted'
};
}
}
\ No newline at end of file
backend/src/modules/adjustments/adjustments.module.ts
0 → 100644
View file @
fcbd2e22
import
{
Module
}
from
'@nestjs/common'
;
import
{
AdjustmentsController
}
from
'./adjustments.controller'
;
import
{
AdjustmentsService
}
from
'./adjustments.service'
;
import
{
HudModule
}
from
'../hud/hud.module'
;
@
Module
({
imports
:
[
HudModule
],
controllers
:
[
AdjustmentsController
],
providers
:
[
AdjustmentsService
],
exports
:
[
AdjustmentsService
],
})
export
class
AdjustmentsModule
{}
\ No newline at end of file
backend/src/modules/adjustments/adjustments.service.ts
0 → 100644
View file @
fcbd2e22
import
{
Injectable
,
NotFoundException
,
ForbiddenException
,
BadRequestException
,
Logger
,
}
from
'@nestjs/common'
;
import
{
PrismaService
}
from
'../../prisma/prisma.service'
;
import
{
HudService
}
from
'../hud/hud.service'
;
import
{
CreateAdjustmentDto
}
from
'./dto/create-adjustment.dto'
;
import
{
AdjustmentFilterDto
}
from
'./dto/adjustment-filter.dto'
;
import
{
RequestUser
}
from
'../../common/decorators/current-user.decorator'
;
import
{
getSkip
,
buildPaginatedResponse
,
PaginatedResult
}
from
'../../common/utils/pagination.util'
;
@
Injectable
()
export
class
AdjustmentsService
{
private
readonly
logger
=
new
Logger
(
AdjustmentsService
.
name
);
constructor
(
private
readonly
prisma
:
PrismaService
,
private
readonly
hudService
:
HudService
,
)
{}
async
create
(
dto
:
CreateAdjustmentDto
,
currentUser
:
RequestUser
):
Promise
<
any
>
{
if
(
currentUser
.
role
!==
'SUPER_ADMIN'
&&
currentUser
.
role
!==
'ADMIN'
)
{
throw
new
ForbiddenException
(
'Only Super Admin and Admin can create adjustments'
);
}
if
(
!
[
'POSITIVE'
,
'NEGATIVE'
].
includes
(
dto
.
type
))
{
throw
new
BadRequestException
(
'Type must be POSITIVE or NEGATIVE'
);
}
const
validCategories
=
[
'ADVANCE'
,
'REIMBURSEMENT'
,
'BONUS'
,
'CORRECTION'
,
'LOAN'
,
'OTHER'
];
if
(
!
validCategories
.
includes
(
dto
.
category
))
{
throw
new
BadRequestException
(
`Category must be one of:
${
validCategories
.
join
(
', '
)}
`
);
}
const
contractor
=
await
this
.
prisma
.
user
.
findFirst
({
where
:
{
id
:
dto
.
userId
,
deletedAt
:
null
},
});
if
(
!
contractor
)
throw
new
NotFoundException
(
'Contractor not found'
);
const
now
=
new
Date
();
const
effectiveMonth
=
dto
.
effectiveMonth
||
now
.
getMonth
()
+
1
;
const
effectiveYear
=
dto
.
effectiveYear
||
now
.
getFullYear
();
// SA creates are auto-approved; Admin creates need SA approval
const
status
=
currentUser
.
role
===
'SUPER_ADMIN'
?
'APPROVED'
:
'PENDING_APPROVAL'
;
const
adjustment
=
await
this
.
prisma
.
adjustment
.
create
({
data
:
{
userId
:
dto
.
userId
,
type
:
dto
.
type
,
category
:
dto
.
category
,
amountPiasters
:
dto
.
amountPiasters
,
description
:
dto
.
description
,
effectiveMonth
,
effectiveYear
,
status
,
createdById
:
currentUser
.
id
,
approvedById
:
status
===
'APPROVED'
?
currentUser
.
id
:
null
,
approvedAt
:
status
===
'APPROVED'
?
new
Date
()
:
null
,
payrollMonth
:
effectiveMonth
,
payrollYear
:
effectiveYear
,
},
include
:
{
user
:
{
select
:
{
id
:
true
,
firstName
:
true
,
lastName
:
true
}
},
createdBy
:
{
select
:
{
id
:
true
,
firstName
:
true
,
lastName
:
true
}
},
},
});
// If auto-approved, push HUD update immediately
if
(
status
===
'APPROVED'
)
{
try
{
await
this
.
hudService
.
pushAdjustmentApplied
(
dto
.
userId
,
dto
.
type
,
dto
.
category
,
dto
.
amountPiasters
,
);
}
catch
(
err
)
{
this
.
logger
.
warn
(
`Failed to push HUD for adjustment:
${
err
.
message
}
`
);
}
}
this
.
logger
.
log
(
`Adjustment created:
${
dto
.
type
}
${
dto
.
category
}
${
dto
.
amountPiasters
}
piasters for
${
contractor
.
firstName
}
by
${
currentUser
.
email
}
(
${
status
}
)`
,
);
return
adjustment
;
}
async
findAll
(
filter
:
AdjustmentFilterDto
,
currentUser
:
RequestUser
):
Promise
<
PaginatedResult
<
any
>>
{
const
page
=
filter
.
page
||
1
;
const
limit
=
filter
.
limit
||
20
;
const
where
:
any
=
{};
if
(
currentUser
.
role
===
'CONTRACTOR'
)
{
where
.
userId
=
currentUser
.
id
;
where
.
status
=
'APPROVED'
;
// Contractors only see approved adjustments
}
if
(
filter
.
userId
)
where
.
userId
=
filter
.
userId
;
if
(
filter
.
type
)
where
.
type
=
filter
.
type
;
if
(
filter
.
category
)
where
.
category
=
filter
.
category
;
if
(
filter
.
status
&&
currentUser
.
role
!==
'CONTRACTOR'
)
where
.
status
=
filter
.
status
;
const
[
data
,
total
]
=
await
Promise
.
all
([
this
.
prisma
.
adjustment
.
findMany
({
where
,
skip
:
getSkip
(
page
,
limit
),
take
:
limit
,
orderBy
:
{
createdAt
:
filter
.
sortOrder
||
'desc'
},
include
:
{
user
:
{
select
:
{
id
:
true
,
firstName
:
true
,
lastName
:
true
,
avatar
:
true
}
},
createdBy
:
{
select
:
{
id
:
true
,
firstName
:
true
,
lastName
:
true
}
},
approvedBy
:
{
select
:
{
id
:
true
,
firstName
:
true
,
lastName
:
true
}
},
},
}),
this
.
prisma
.
adjustment
.
count
({
where
}),
]);
return
buildPaginatedResponse
(
data
,
total
,
{
page
,
limit
,
sortOrder
:
filter
.
sortOrder
||
'desc'
});
}
async
findById
(
id
:
string
,
currentUser
:
RequestUser
):
Promise
<
any
>
{
const
adjustment
=
await
this
.
prisma
.
adjustment
.
findUnique
({
where
:
{
id
},
include
:
{
user
:
{
select
:
{
id
:
true
,
firstName
:
true
,
lastName
:
true
,
avatar
:
true
}
},
createdBy
:
{
select
:
{
id
:
true
,
firstName
:
true
,
lastName
:
true
}
},
approvedBy
:
{
select
:
{
id
:
true
,
firstName
:
true
,
lastName
:
true
}
},
},
});
if
(
!
adjustment
)
throw
new
NotFoundException
(
'Adjustment not found'
);
if
(
currentUser
.
role
===
'CONTRACTOR'
&&
adjustment
.
userId
!==
currentUser
.
id
)
{
throw
new
ForbiddenException
(
'You can only view your own adjustments'
);
}
return
adjustment
;
}
async
review
(
id
:
string
,
decision
:
'APPROVED'
|
'REJECTED'
,
reason
:
string
|
undefined
,
currentUser
:
RequestUser
):
Promise
<
any
>
{
if
(
currentUser
.
role
!==
'SUPER_ADMIN'
)
{
throw
new
ForbiddenException
(
'Only Super Admin can approve/reject adjustments'
);
}
const
adjustment
=
await
this
.
prisma
.
adjustment
.
findUnique
({
where
:
{
id
}
});
if
(
!
adjustment
)
throw
new
NotFoundException
(
'Adjustment not found'
);
if
(
adjustment
.
status
!==
'PENDING_APPROVAL'
)
{
throw
new
BadRequestException
(
'This adjustment is not pending approval'
);
}
const
updated
=
await
this
.
prisma
.
adjustment
.
update
({
where
:
{
id
},
data
:
{
status
:
decision
,
approvedById
:
decision
===
'APPROVED'
?
currentUser
.
id
:
null
,
approvedAt
:
decision
===
'APPROVED'
?
new
Date
()
:
null
,
rejectionReason
:
decision
===
'REJECTED'
?
reason
||
'Rejected by Super Admin'
:
null
,
},
});
if
(
decision
===
'APPROVED'
)
{
try
{
await
this
.
hudService
.
pushAdjustmentApplied
(
adjustment
.
userId
,
adjustment
.
type
,
adjustment
.
category
,
adjustment
.
amountPiasters
,
);
}
catch
(
err
)
{
this
.
logger
.
warn
(
`Failed to push HUD for approved adjustment:
${
err
.
message
}
`
);
}
}
this
.
logger
.
log
(
`Adjustment
${
id
}
${
decision
}
by
${
currentUser
.
email
}
`
);
return
updated
;
}
async
update
(
id
:
string
,
data
:
any
,
currentUser
:
RequestUser
):
Promise
<
any
>
{
if
(
currentUser
.
role
!==
'SUPER_ADMIN'
)
{
throw
new
ForbiddenException
(
'Only Super Admin can edit adjustments'
);
}
const
adjustment
=
await
this
.
prisma
.
adjustment
.
findUnique
({
where
:
{
id
}
});
if
(
!
adjustment
)
throw
new
NotFoundException
(
'Adjustment not found'
);
const
updateData
:
any
=
{};
if
(
data
.
amountPiasters
!==
undefined
)
updateData
.
amountPiasters
=
data
.
amountPiasters
;
if
(
data
.
description
!==
undefined
)
updateData
.
description
=
data
.
description
;
if
(
data
.
type
!==
undefined
)
updateData
.
type
=
data
.
type
;
if
(
data
.
category
!==
undefined
)
updateData
.
category
=
data
.
category
;
const
updated
=
await
this
.
prisma
.
adjustment
.
update
({
where
:
{
id
},
data
:
updateData
});
if
(
adjustment
.
status
===
'APPROVED'
)
{
try
{
await
this
.
hudService
.
pushHudUpdate
(
adjustment
.
userId
);
}
catch
(
err
)
{
this
.
logger
.
warn
(
`Failed to push HUD after adjustment edit:
${
err
.
message
}
`
);
}
}
return
updated
;
}
async
delete
(
id
:
string
,
currentUser
:
RequestUser
):
Promise
<
void
>
{
if
(
currentUser
.
role
!==
'SUPER_ADMIN'
)
{
throw
new
ForbiddenException
(
'Only Super Admin can delete adjustments'
);
}
const
adjustment
=
await
this
.
prisma
.
adjustment
.
findUnique
({
where
:
{
id
}
});
if
(
!
adjustment
)
throw
new
NotFoundException
(
'Adjustment not found'
);
await
this
.
prisma
.
adjustment
.
delete
({
where
:
{
id
}
});
if
(
adjustment
.
status
===
'APPROVED'
)
{
try
{
await
this
.
hudService
.
pushHudUpdate
(
adjustment
.
userId
);
}
catch
(
err
)
{
this
.
logger
.
warn
(
`Failed to push HUD after adjustment deletion:
${
err
.
message
}
`
);
}
}
this
.
logger
.
log
(
`Adjustment
${
id
}
deleted by
${
currentUser
.
email
}
`
);
}
}
\ No newline at end of file
backend/src/modules/adjustments/dto/adjustment-filter.dto.ts
0 → 100644
View file @
fcbd2e22
import
{
IsOptional
,
IsString
}
from
'class-validator'
;
import
{
PaginationDto
}
from
'../../../common/dto/pagination.dto'
;
export
class
AdjustmentFilterDto
extends
PaginationDto
{
@
IsOptional
()
@
IsString
()
userId
?:
string
;
@
IsOptional
()
@
IsString
()
type
?:
string
;
@
IsOptional
()
@
IsString
()
category
?:
string
;
@
IsOptional
()
@
IsString
()
status
?:
string
;
}
\ No newline at end of file
backend/src/modules/adjustments/dto/create-adjustment.dto.ts
0 → 100644
View file @
fcbd2e22
import
{
IsString
,
IsInt
,
IsOptional
,
Min
,
MinLength
}
from
'class-validator'
;
export
class
CreateAdjustmentDto
{
@
IsString
()
userId
:
string
;
@
IsString
()
type
:
string
;
// POSITIVE, NEGATIVE
@
IsString
()
category
:
string
;
// ADVANCE, REIMBURSEMENT, BONUS, CORRECTION, LOAN, OTHER
@
IsInt
()
@
Min
(
1
)
amountPiasters
:
number
;
@
IsString
()
@
MinLength
(
50
,
{
message
:
'Description must be at least 50 characters'
})
description
:
string
;
@
IsOptional
()
@
IsInt
()
effectiveMonth
?:
number
;
@
IsOptional
()
@
IsInt
()
effectiveYear
?:
number
;
}
\ No newline at end of file
backend/src/modules/bounties/bounties.controller.ts
0 → 100644
View file @
fcbd2e22
import
{
Controller
,
Get
,
Param
,
Query
}
from
'@nestjs/common'
;
import
{
BountiesService
}
from
'./bounties.service'
;
import
{
CurrentUser
,
RequestUser
}
from
'../../common/decorators/current-user.decorator'
;
import
{
Roles
}
from
'../../common/decorators/roles.decorator'
;
@
Controller
(
'bounties'
)
export
class
BountiesController
{
constructor
(
private
readonly
bountiesService
:
BountiesService
)
{}
@
Get
(
'dashboard'
)
@
Roles
(
'SUPER_ADMIN'
,
'ADMIN'
)
async
getDashboard
(@
CurrentUser
()
user
:
RequestUser
)
{
return
this
.
bountiesService
.
getDashboard
(
user
);
}
@
Get
(
'my'
)
async
getMyBounties
(
@
CurrentUser
()
user
:
RequestUser
,
@
Query
(
'month'
)
month
?:
string
,
@
Query
(
'year'
)
year
?:
string
,
)
{
return
this
.
bountiesService
.
getPayoutsForUser
(
user
.
id
,
month
?
parseInt
(
month
,
10
)
:
undefined
,
year
?
parseInt
(
year
,
10
)
:
undefined
,
);
}
@
Get
(
'user/:userId'
)
@
Roles
(
'SUPER_ADMIN'
,
'ADMIN'
)
async
getUserBounties
(
@
Param
(
'userId'
)
userId
:
string
,
@
Query
(
'month'
)
month
?:
string
,
@
Query
(
'year'
)
year
?:
string
,
)
{
return
this
.
bountiesService
.
getPayoutsForUser
(
userId
,
month
?
parseInt
(
month
,
10
)
:
undefined
,
year
?
parseInt
(
year
,
10
)
:
undefined
,
);
}
}
\ No newline at end of file
backend/src/modules/bounties/bounties.module.ts
0 → 100644
View file @
fcbd2e22
import
{
Module
}
from
'@nestjs/common'
;
import
{
BountiesController
}
from
'./bounties.controller'
;
import
{
BountiesService
}
from
'./bounties.service'
;
import
{
HudModule
}
from
'../hud/hud.module'
;
@
Module
({
imports
:
[
HudModule
],
controllers
:
[
BountiesController
],
providers
:
[
BountiesService
],
exports
:
[
BountiesService
],
})
export
class
BountiesModule
{}
\ No newline at end of file
backend/src/modules/bounties/bounties.service.ts
0 → 100644
View file @
fcbd2e22
import
{
Injectable
,
NotFoundException
,
BadRequestException
,
ForbiddenException
,
Logger
,
}
from
'@nestjs/common'
;
import
{
PrismaService
}
from
'../../prisma/prisma.service'
;
import
{
HudService
}
from
'../hud/hud.service'
;
import
{
RequestUser
}
from
'../../common/decorators/current-user.decorator'
;
@
Injectable
()
export
class
BountiesService
{
private
readonly
logger
=
new
Logger
(
BountiesService
.
name
);
constructor
(
private
readonly
prisma
:
PrismaService
,
private
readonly
hudService
:
HudService
,
)
{}
async
payoutBounty
(
cardId
:
string
,
movedBy
:
RequestUser
):
Promise
<
any
[]
>
{
const
card
=
await
this
.
prisma
.
card
.
findFirst
({
where
:
{
id
:
cardId
,
deletedAt
:
null
},
include
:
{
assignees
:
{
select
:
{
id
:
true
,
firstName
:
true
,
lastName
:
true
}
},
column
:
{
select
:
{
boardId
:
true
,
board
:
{
select
:
{
key
:
true
,
name
:
true
}
}
}
},
},
});
if
(
!
card
)
throw
new
NotFoundException
(
'Card not found'
);
if
(
!
card
.
bountyPiasters
||
card
.
bountyPiasters
<=
0
)
return
[];
const
assignees
=
card
.
assignees
;
if
(
assignees
.
length
===
0
)
{
this
.
logger
.
warn
(
`Card
${
cardId
}
has bounty but no assignees. Bounty not paid.`
);
return
[];
}
// Parse bounty split
let
splits
:
Record
<
string
,
number
>
=
{};
if
(
card
.
bountySplit
&&
typeof
card
.
bountySplit
===
'object'
)
{
splits
=
card
.
bountySplit
as
Record
<
string
,
number
>
;
}
// Default: equal split
if
(
Object
.
keys
(
splits
).
length
===
0
)
{
const
equalShare
=
100
/
assignees
.
length
;
for
(
const
a
of
assignees
)
{
splits
[
a
.
id
]
=
equalShare
;
}
}
const
now
=
new
Date
();
const
payouts
:
any
[]
=
[];
for
(
const
assignee
of
assignees
)
{
const
percentage
=
splits
[
assignee
.
id
]
||
(
100
/
assignees
.
length
);
const
amount
=
Math
.
round
((
card
.
bountyPiasters
*
percentage
)
/
100
);
if
(
amount
<=
0
)
continue
;
const
payout
=
await
this
.
prisma
.
bountyPayout
.
create
({
data
:
{
cardId
:
card
.
id
,
userId
:
assignee
.
id
,
amountPiasters
:
amount
,
splitPercentage
:
percentage
,
boardId
:
card
.
column
.
boardId
,
cardNumber
:
card
.
cardNumber
,
cardTitle
:
card
.
title
,
payrollMonth
:
now
.
getMonth
()
+
1
,
payrollYear
:
now
.
getFullYear
(),
},
});
payouts
.
push
(
payout
);
// Push HUD update for each assignee
try
{
await
this
.
hudService
.
pushBountyEarned
(
assignee
.
id
,
card
.
title
,
amount
);
}
catch
(
err
)
{
this
.
logger
.
warn
(
`Failed to push bounty HUD for
${
assignee
.
id
}
:
${
err
.
message
}
`
);
}
this
.
logger
.
log
(
`Bounty payout:
${
amount
}
piasters to
${
assignee
.
firstName
}
${
assignee
.
lastName
}
for card
${
card
.
cardNumber
}
`
,
);
}
return
payouts
;
}
async
getDashboard
(
currentUser
:
RequestUser
):
Promise
<
any
>
{
if
(
currentUser
.
role
!==
'SUPER_ADMIN'
&&
currentUser
.
role
!==
'ADMIN'
)
{
throw
new
ForbiddenException
(
'Only Super Admin and Admin can view bounty dashboard'
);
}
const
now
=
new
Date
();
const
month
=
now
.
getMonth
()
+
1
;
const
year
=
now
.
getFullYear
();
// Total bounties paid this month
const
paidThisMonth
=
await
this
.
prisma
.
bountyPayout
.
aggregate
({
where
:
{
payrollMonth
:
month
,
payrollYear
:
year
,
revokedAt
:
null
},
_sum
:
{
amountPiasters
:
true
},
_count
:
true
,
});
// Pending bounties (cards with bounty not yet in Done)
const
pendingBounties
=
await
this
.
prisma
.
card
.
aggregate
({
where
:
{
bountyPiasters
:
{
gt
:
0
},
completedAt
:
null
,
deletedAt
:
null
,
isArchived
:
false
,
},
_sum
:
{
bountyPiasters
:
true
},
_count
:
true
,
});
// Top earners this month
const
topEarners
=
await
this
.
prisma
.
bountyPayout
.
groupBy
({
by
:
[
'userId'
],
where
:
{
payrollMonth
:
month
,
payrollYear
:
year
,
revokedAt
:
null
},
_sum
:
{
amountPiasters
:
true
},
_count
:
true
,
orderBy
:
{
_sum
:
{
amountPiasters
:
'desc'
}
},
take
:
10
,
});
// Enrich top earners with user data
const
enrichedEarners
=
[];
for
(
const
earner
of
topEarners
)
{
const
user
=
await
this
.
prisma
.
user
.
findUnique
({
where
:
{
id
:
earner
.
userId
},
select
:
{
id
:
true
,
firstName
:
true
,
lastName
:
true
,
avatar
:
true
,
actualSalaryPiasters
:
true
},
});
enrichedEarners
.
push
({
user
,
totalPiasters
:
earner
.
_sum
.
amountPiasters
,
count
:
earner
.
_count
,
salaryRatio
:
user
?.
actualSalaryPiasters
?
Math
.
round
(((
earner
.
_sum
.
amountPiasters
||
0
)
/
user
.
actualSalaryPiasters
)
*
100
)
:
0
,
});
}
// Admin budget tracking
let
adminBudgetCap
=
5000000
;
// 50,000 EGP default
try
{
const
setting
=
await
this
.
prisma
.
setting
.
findUnique
({
where
:
{
key
:
'adminMonthlyBountyBudgetPiasters'
},
});
if
(
setting
)
adminBudgetCap
=
setting
.
value
as
number
;
}
catch
{
/* settings may not exist */
}
// Admin-assigned bounties this month (exclude SA)
const
adminBounties
=
await
this
.
prisma
.
bountyPayout
.
aggregate
({
where
:
{
payrollMonth
:
month
,
payrollYear
:
year
,
revokedAt
:
null
,
// Would need to track who set the bounty — simplified for now
},
_sum
:
{
amountPiasters
:
true
},
});
return
{
thisMonth
:
{
paidCount
:
paidThisMonth
.
_count
,
paidTotalPiasters
:
paidThisMonth
.
_sum
.
amountPiasters
||
0
,
},
pending
:
{
count
:
pendingBounties
.
_count
,
totalPiasters
:
pendingBounties
.
_sum
.
bountyPiasters
||
0
,
},
topEarners
:
enrichedEarners
,
adminBudget
:
{
capPiasters
:
adminBudgetCap
,
usedPiasters
:
adminBounties
.
_sum
.
amountPiasters
||
0
,
remainingPiasters
:
adminBudgetCap
-
(
adminBounties
.
_sum
.
amountPiasters
||
0
),
},
};
}
async
getPayoutsForUser
(
userId
:
string
,
month
?:
number
,
year
?:
number
):
Promise
<
any
[]
>
{
const
now
=
new
Date
();
const
m
=
month
||
now
.
getMonth
()
+
1
;
const
y
=
year
||
now
.
getFullYear
();
return
this
.
prisma
.
bountyPayout
.
findMany
({
where
:
{
userId
,
payrollMonth
:
m
,
payrollYear
:
y
,
revokedAt
:
null
,
},
orderBy
:
{
paidAt
:
'desc'
},
});
}
}
\ No newline at end of file
backend/src/modules/cards/card-movement.service.ts
View file @
fcbd2e22
...
...
@@ -5,6 +5,8 @@ import {
BadRequestException
,
ConflictException
,
Logger
,
Inject
,
forwardRef
,
}
from
'@nestjs/common'
;
import
{
PrismaService
}
from
'../../prisma/prisma.service'
;
import
{
MoveCardDto
}
from
'./dto/move-card.dto'
;
...
...
@@ -121,6 +123,11 @@ export class CardMovementService {
data
:
updateData
,
});
// ─── BOUNTY PAYOUT ON DONE ───────────────────────────────────
if
(
targetColumn
.
type
===
'DONE'
&&
card
.
bountyPiasters
&&
card
.
bountyPiasters
>
0
)
{
await
this
.
triggerBountyPayout
(
card
);
}
// ─── LOG ACTIVITY ────────────────────────────────────────────
try
{
await
this
.
prisma
.
cardActivity
.
create
({
...
...
@@ -148,6 +155,65 @@ export class CardMovementService {
return
updated
;
}
private
async
triggerBountyPayout
(
card
:
any
):
Promise
<
void
>
{
try
{
const
assignees
=
await
this
.
prisma
.
card
.
findUnique
({
where
:
{
id
:
card
.
id
},
select
:
{
assignees
:
{
select
:
{
id
:
true
,
firstName
:
true
,
lastName
:
true
}
},
bountyPiasters
:
true
,
bountySplit
:
true
,
title
:
true
,
cardNumber
:
true
,
column
:
{
select
:
{
boardId
:
true
}
},
},
});
if
(
!
assignees
||
!
assignees
.
bountyPiasters
||
assignees
.
assignees
.
length
===
0
)
return
;
let
splits
:
Record
<
string
,
number
>
=
{};
if
(
assignees
.
bountySplit
&&
typeof
assignees
.
bountySplit
===
'object'
)
{
splits
=
assignees
.
bountySplit
as
Record
<
string
,
number
>
;
}
if
(
Object
.
keys
(
splits
).
length
===
0
)
{
const
equalShare
=
100
/
assignees
.
assignees
.
length
;
for
(
const
a
of
assignees
.
assignees
)
{
splits
[
a
.
id
]
=
equalShare
;
}
}
const
now
=
new
Date
();
for
(
const
assignee
of
assignees
.
assignees
)
{
const
percentage
=
splits
[
assignee
.
id
]
||
(
100
/
assignees
.
assignees
.
length
);
const
amount
=
Math
.
round
((
assignees
.
bountyPiasters
*
percentage
)
/
100
);
if
(
amount
<=
0
)
continue
;
await
this
.
prisma
.
bountyPayout
.
create
({
data
:
{
cardId
:
card
.
id
,
userId
:
assignee
.
id
,
amountPiasters
:
amount
,
splitPercentage
:
percentage
,
boardId
:
assignees
.
column
.
boardId
,
cardNumber
:
assignees
.
cardNumber
,
cardTitle
:
assignees
.
title
,
payrollMonth
:
now
.
getMonth
()
+
1
,
payrollYear
:
now
.
getFullYear
(),
},
});
this
.
logger
.
log
(
`Bounty payout:
${
amount
}
piasters to
${
assignee
.
firstName
}
${
assignee
.
lastName
}
for
${
assignees
.
cardNumber
}
`
,
);
}
}
catch
(
err
)
{
this
.
logger
.
error
(
`Failed to process bounty payout for card
${
card
.
id
}
:
${
err
.
message
}
`
);
}
}
private
enforceMovementPermissions
(
currentUser
:
RequestUser
,
sourceColumn
:
any
,
...
...
backend/src/modules/deductions/deduction-calculator.service.ts
0 → 100644
View file @
fcbd2e22
import
{
Injectable
,
Logger
}
from
'@nestjs/common'
;
import
{
SalaryService
}
from
'../salary/salary.service'
;
@
Injectable
()
export
class
DeductionCalculatorService
{
private
readonly
logger
=
new
Logger
(
DeductionCalculatorService
.
name
);
constructor
(
private
readonly
salaryService
:
SalaryService
)
{}
async
calculate
(
userId
:
string
,
category
:
string
,
subCategory
:
string
,
context
:
{
daysLate
?:
number
;
month
:
number
;
year
:
number
;
estimatedTaskValuePiasters
?:
number
;
},
):
Promise
<
number
>
{
const
dailyRate
=
await
this
.
salaryService
.
getDailyRate
(
userId
,
context
.
month
,
context
.
year
);
// Get monthly salary for percentage-based calculations
const
liveSalary
=
await
this
.
salaryService
.
calculateLiveSalary
(
userId
,
context
.
month
,
context
.
year
);
const
monthlySalary
=
liveSalary
.
actualSalaryPiasters
;
switch
(
subCategory
)
{
// Category A — Deadline Violations
case
'A1'
:
// Slight Delay (1-3 days)
return
Math
.
round
(
dailyRate
*
0.05
*
(
context
.
daysLate
||
1
));
case
'A2'
:
// Moderate Delay (4-7 days)
return
Math
.
round
(
dailyRate
*
0.10
*
(
context
.
daysLate
||
4
));
case
'A3'
:
// Severe Delay (8-14 days)
return
Math
.
round
(
dailyRate
*
0.15
*
(
context
.
daysLate
||
8
));
case
'A4'
:
// Critical Delay (15+ days)
return
Math
.
round
(
monthlySalary
*
0.25
);
case
'A5'
:
// Complete Failure
return
Math
.
round
((
context
.
estimatedTaskValuePiasters
||
monthlySalary
)
*
0.50
);
// Category B — Reporting Violations
case
'B1'
:
// Late Report (3rd+ occurrence)
return
Math
.
round
(
dailyRate
*
0.02
);
case
'B2'
:
// Unreported Day
return
dailyRate
;
case
'B3'
:
// Vague Report (3+ in a month)
return
Math
.
round
(
monthlySalary
*
0.05
);
case
'B4'
:
// Falsified Report
return
Math
.
round
(
monthlySalary
*
0.25
);
// Category C — Quality Violations
case
'C1'
:
// Minor Quality Issues
return
Math
.
round
((
context
.
estimatedTaskValuePiasters
||
dailyRate
)
*
0.03
);
case
'C2'
:
// Significant Quality Issues
return
Math
.
round
((
context
.
estimatedTaskValuePiasters
||
dailyRate
)
*
0.10
);
case
'C3'
:
// Critical Quality Issues
return
Math
.
round
((
context
.
estimatedTaskValuePiasters
||
dailyRate
)
*
0.25
);
case
'C4'
:
// Regression
return
Math
.
round
(
monthlySalary
*
0.15
);
// Category D — Communication Violations
case
'D1'
:
// Slow Response (2nd+ occurrence)
return
Math
.
round
(
dailyRate
*
0.02
);
case
'D2'
:
// No-Show Meeting
return
Math
.
round
(
dailyRate
*
0.05
);
case
'D3'
:
// Disappeared (3+ days)
return
Math
.
round
(
monthlySalary
*
0.15
);
case
'D4'
:
// Unprofessional Conduct
return
Math
.
round
(
monthlySalary
*
0.10
);
// Default 10%, can be adjusted up to 25%
default
:
this
.
logger
.
warn
(
`Unknown deduction sub-category:
${
subCategory
}
`
);
return
0
;
}
}
getSubCategoryName
(
subCategory
:
string
):
string
{
const
names
:
Record
<
string
,
string
>
=
{
A1
:
'Slight Delay (1-3 days)'
,
A2
:
'Moderate Delay (4-7 days)'
,
A3
:
'Severe Delay (8-14 days)'
,
A4
:
'Critical Delay (15+ days)'
,
A5
:
'Complete Failure'
,
B1
:
'Late Report'
,
B2
:
'Unreported Day'
,
B3
:
'Vague/Useless Report'
,
B4
:
'Falsified Report'
,
C1
:
'Minor Quality Issues'
,
C2
:
'Significant Quality Issues'
,
C3
:
'Critical Quality Issues'
,
C4
:
'Regression'
,
D1
:
'Slow Response'
,
D2
:
'No-Show Meeting'
,
D3
:
'Disappeared'
,
D4
:
'Unprofessional Conduct'
,
};
return
names
[
subCategory
]
||
subCategory
;
}
}
\ No newline at end of file
backend/src/modules/deductions/deduction-preset.service.ts
0 → 100644
View file @
fcbd2e22
import
{
Injectable
,
NotFoundException
,
ForbiddenException
,
ConflictException
,
Logger
,
}
from
'@nestjs/common'
;
import
{
PrismaService
}
from
'../../prisma/prisma.service'
;
import
{
RequestUser
}
from
'../../common/decorators/current-user.decorator'
;
@
Injectable
()
export
class
DeductionPresetService
{
private
readonly
logger
=
new
Logger
(
DeductionPresetService
.
name
);
constructor
(
private
readonly
prisma
:
PrismaService
)
{}
async
findAll
():
Promise
<
any
[]
>
{
return
this
.
prisma
.
deductionPreset
.
findMany
({
where
:
{
isActive
:
true
},
orderBy
:
[{
category
:
'asc'
},
{
subCategory
:
'asc'
}],
include
:
{
createdBy
:
{
select
:
{
id
:
true
,
firstName
:
true
,
lastName
:
true
}
},
},
});
}
async
create
(
data
:
{
name
:
string
;
category
:
string
;
subCategory
:
string
;
description
:
string
;
calculationFormula
:
string
;
},
currentUser
:
RequestUser
):
Promise
<
any
>
{
if
(
currentUser
.
role
!==
'SUPER_ADMIN'
&&
currentUser
.
role
!==
'ADMIN'
)
{
throw
new
ForbiddenException
(
'Only Super Admin and Admin can manage deduction presets'
);
}
const
existing
=
await
this
.
prisma
.
deductionPreset
.
findUnique
({
where
:
{
name
:
data
.
name
},
});
if
(
existing
)
{
throw
new
ConflictException
(
`Preset "
${
data
.
name
}
" already exists`
);
}
return
this
.
prisma
.
deductionPreset
.
create
({
data
:
{
...
data
,
createdById
:
currentUser
.
id
,
},
});
}
async
update
(
id
:
string
,
data
:
Partial
<
{
name
:
string
;
description
:
string
;
calculationFormula
:
string
;
isActive
:
boolean
;
}
>
,
currentUser
:
RequestUser
):
Promise
<
any
>
{
if
(
currentUser
.
role
!==
'SUPER_ADMIN'
&&
currentUser
.
role
!==
'ADMIN'
)
{
throw
new
ForbiddenException
(
'Only Super Admin and Admin can manage deduction presets'
);
}
const
preset
=
await
this
.
prisma
.
deductionPreset
.
findUnique
({
where
:
{
id
}
});
if
(
!
preset
)
throw
new
NotFoundException
(
'Preset not found'
);
return
this
.
prisma
.
deductionPreset
.
update
({
where
:
{
id
},
data
});
}
async
delete
(
id
:
string
,
currentUser
:
RequestUser
):
Promise
<
void
>
{
if
(
currentUser
.
role
!==
'SUPER_ADMIN'
)
{
throw
new
ForbiddenException
(
'Only Super Admin can delete deduction presets'
);
}
const
preset
=
await
this
.
prisma
.
deductionPreset
.
findUnique
({
where
:
{
id
}
});
if
(
!
preset
)
throw
new
NotFoundException
(
'Preset not found'
);
await
this
.
prisma
.
deductionPreset
.
delete
({
where
:
{
id
}
});
}
}
\ No newline at end of file
backend/src/modules/deductions/deductions.controller.ts
0 → 100644
View file @
fcbd2e22
import
{
Controller
,
Get
,
Post
,
Put
,
Delete
,
Body
,
Param
,
Query
,
HttpCode
,
HttpStatus
,
}
from
'@nestjs/common'
;
import
{
DeductionsService
}
from
'./deductions.service'
;
import
{
DeductionPresetService
}
from
'./deduction-preset.service'
;
import
{
CreateDeductionDto
}
from
'./dto/create-deduction.dto'
;
import
{
RespondDeductionDto
}
from
'./dto/respond-deduction.dto'
;
import
{
ReviewDeductionDto
}
from
'./dto/review-deduction.dto'
;
import
{
DeductionFilterDto
}
from
'./dto/deduction-filter.dto'
;
import
{
CurrentUser
,
RequestUser
}
from
'../../common/decorators/current-user.decorator'
;
import
{
Roles
}
from
'../../common/decorators/roles.decorator'
;
@
Controller
(
'deductions'
)
export
class
DeductionsController
{
constructor
(
private
readonly
deductionsService
:
DeductionsService
,
private
readonly
presetService
:
DeductionPresetService
,
)
{}
@
Post
()
@
Roles
(
'SUPER_ADMIN'
,
'ADMIN'
,
'TEAM_LEAD'
)
async
create
(@
Body
()
dto
:
CreateDeductionDto
,
@
CurrentUser
()
user
:
RequestUser
)
{
return
this
.
deductionsService
.
create
(
dto
,
user
);
}
@
Get
()
async
findAll
(@
Query
()
filter
:
DeductionFilterDto
,
@
CurrentUser
()
user
:
RequestUser
)
{
return
this
.
deductionsService
.
findAll
(
filter
,
user
);
}
@
Get
(
'presets'
)
@
Roles
(
'SUPER_ADMIN'
,
'ADMIN'
)
async
getPresets
()
{
return
this
.
presetService
.
findAll
();
}
@
Get
(
':id'
)
async
findById
(@
Param
(
'id'
)
id
:
string
,
@
CurrentUser
()
user
:
RequestUser
)
{
return
this
.
deductionsService
.
findById
(
id
,
user
);
}
@
Post
(
':id/acknowledge'
)
@
HttpCode
(
HttpStatus
.
OK
)
async
acknowledge
(@
Param
(
'id'
)
id
:
string
,
@
CurrentUser
()
user
:
RequestUser
)
{
return
this
.
deductionsService
.
acknowledge
(
id
,
user
);
}
@
Post
(
':id/respond'
)
@
HttpCode
(
HttpStatus
.
OK
)
async
respond
(
@
Param
(
'id'
)
id
:
string
,
@
Body
()
dto
:
RespondDeductionDto
,
@
CurrentUser
()
user
:
RequestUser
,
)
{
return
this
.
deductionsService
.
respond
(
id
,
dto
,
user
);
}
@
Put
(
':id/admin-review'
)
@
Roles
(
'SUPER_ADMIN'
,
'ADMIN'
)
async
reviewDraft
(
@
Param
(
'id'
)
id
:
string
,
@
Body
(
'decision'
)
decision
:
string
,
@
CurrentUser
()
user
:
RequestUser
,
)
{
return
this
.
deductionsService
.
reviewAdminDraft
(
id
,
decision
,
user
);
}
@
Put
(
':id/review'
)
@
Roles
(
'SUPER_ADMIN'
,
'ADMIN'
)
async
review
(
@
Param
(
'id'
)
id
:
string
,
@
Body
()
dto
:
ReviewDeductionDto
,
@
CurrentUser
()
user
:
RequestUser
,
)
{
return
this
.
deductionsService
.
review
(
id
,
dto
,
user
);
}
@
Put
(
':id'
)
@
Roles
(
'SUPER_ADMIN'
)
async
update
(
@
Param
(
'id'
)
id
:
string
,
@
Body
()
data
:
any
,
@
CurrentUser
()
user
:
RequestUser
,
)
{
return
this
.
deductionsService
.
update
(
id
,
data
,
user
);
}
@
Delete
(
':id'
)
@
Roles
(
'SUPER_ADMIN'
)
@
HttpCode
(
HttpStatus
.
OK
)
async
delete
(@
Param
(
'id'
)
id
:
string
,
@
CurrentUser
()
user
:
RequestUser
)
{
await
this
.
deductionsService
.
delete
(
id
,
user
);
return
{
message
:
'Deduction deleted'
};
}
// ─── PRESETS ───────────────────────────────────────────
@
Post
(
'presets'
)
@
Roles
(
'SUPER_ADMIN'
,
'ADMIN'
)
async
createPreset
(@
Body
()
data
:
any
,
@
CurrentUser
()
user
:
RequestUser
)
{
return
this
.
presetService
.
create
(
data
,
user
);
}
@
Put
(
'presets/:id'
)
@
Roles
(
'SUPER_ADMIN'
,
'ADMIN'
)
async
updatePreset
(@
Param
(
'id'
)
id
:
string
,
@
Body
()
data
:
any
,
@
CurrentUser
()
user
:
RequestUser
)
{
return
this
.
presetService
.
update
(
id
,
data
,
user
);
}
@
Delete
(
'presets/:id'
)
@
Roles
(
'SUPER_ADMIN'
)
@
HttpCode
(
HttpStatus
.
OK
)
async
deletePreset
(@
Param
(
'id'
)
id
:
string
,
@
CurrentUser
()
user
:
RequestUser
)
{
await
this
.
presetService
.
delete
(
id
,
user
);
return
{
message
:
'Preset deleted'
};
}
}
\ No newline at end of file
backend/src/modules/deductions/deductions.module.ts
0 → 100644
View file @
fcbd2e22
import
{
Module
}
from
'@nestjs/common'
;
import
{
DeductionsController
}
from
'./deductions.controller'
;
import
{
DeductionsService
}
from
'./deductions.service'
;
import
{
DeductionCalculatorService
}
from
'./deduction-calculator.service'
;
import
{
DeductionPresetService
}
from
'./deduction-preset.service'
;
import
{
SalaryModule
}
from
'../salary/salary.module'
;
import
{
HudModule
}
from
'../hud/hud.module'
;
@
Module
({
imports
:
[
SalaryModule
,
HudModule
],
controllers
:
[
DeductionsController
],
providers
:
[
DeductionsService
,
DeductionCalculatorService
,
DeductionPresetService
],
exports
:
[
DeductionsService
,
DeductionCalculatorService
],
})
export
class
DeductionsModule
{}
\ No newline at end of file
backend/src/modules/deductions/deductions.service.ts
0 → 100644
View file @
fcbd2e22
import
{
Injectable
,
NotFoundException
,
ForbiddenException
,
BadRequestException
,
Logger
,
}
from
'@nestjs/common'
;
import
{
PrismaService
}
from
'../../prisma/prisma.service'
;
import
{
DeductionCalculatorService
}
from
'./deduction-calculator.service'
;
import
{
HudService
}
from
'../hud/hud.service'
;
import
{
CreateDeductionDto
}
from
'./dto/create-deduction.dto'
;
import
{
RespondDeductionDto
}
from
'./dto/respond-deduction.dto'
;
import
{
ReviewDeductionDto
}
from
'./dto/review-deduction.dto'
;
import
{
DeductionFilterDto
}
from
'./dto/deduction-filter.dto'
;
import
{
RequestUser
}
from
'../../common/decorators/current-user.decorator'
;
import
{
getSkip
,
buildPaginatedResponse
,
PaginatedResult
}
from
'../../common/utils/pagination.util'
;
@
Injectable
()
export
class
DeductionsService
{
private
readonly
logger
=
new
Logger
(
DeductionsService
.
name
);
constructor
(
private
readonly
prisma
:
PrismaService
,
private
readonly
calculator
:
DeductionCalculatorService
,
private
readonly
hudService
:
HudService
,
)
{}
async
create
(
dto
:
CreateDeductionDto
,
currentUser
:
RequestUser
):
Promise
<
any
>
{
// Verify contractor exists
const
contractor
=
await
this
.
prisma
.
user
.
findFirst
({
where
:
{
id
:
dto
.
userId
,
deletedAt
:
null
},
});
if
(
!
contractor
)
throw
new
NotFoundException
(
'Contractor not found'
);
// Validate category/subCategory
const
validSubs
:
Record
<
string
,
string
[]
>
=
{
A
:
[
'A1'
,
'A2'
,
'A3'
,
'A4'
,
'A5'
],
B
:
[
'B1'
,
'B2'
,
'B3'
,
'B4'
],
C
:
[
'C1'
,
'C2'
,
'C3'
,
'C4'
],
D
:
[
'D1'
,
'D2'
,
'D3'
,
'D4'
],
};
if
(
!
validSubs
[
dto
.
category
]
||
!
validSubs
[
dto
.
category
].
includes
(
dto
.
subCategory
))
{
throw
new
BadRequestException
(
`Invalid sub-category "
${
dto
.
subCategory
}
" for category "
${
dto
.
category
}
"`
);
}
// Calculate amount if not provided
const
now
=
new
Date
();
let
amountPiasters
=
dto
.
amountPiasters
||
0
;
if
(
!
dto
.
amountPiasters
)
{
amountPiasters
=
await
this
.
calculator
.
calculate
(
dto
.
userId
,
dto
.
category
,
dto
.
subCategory
,
{
month
:
now
.
getMonth
()
+
1
,
year
:
now
.
getFullYear
(),
});
}
// Determine initial status based on initiator role
let
status
:
string
;
if
(
currentUser
.
role
===
'TEAM_LEAD'
)
{
status
=
'PENDING_ADMIN_REVIEW'
;
}
else
{
status
=
'PENDING_ACKNOWLEDGMENT'
;
}
const
deduction
=
await
this
.
prisma
.
deduction
.
create
({
data
:
{
userId
:
dto
.
userId
,
category
:
dto
.
category
,
subCategory
:
dto
.
subCategory
,
cardId
:
dto
.
cardId
||
null
,
reportId
:
dto
.
reportId
||
null
,
violationDate
:
new
Date
(
dto
.
violationDate
),
description
:
dto
.
description
,
evidence
:
dto
.
evidence
||
null
,
amountPiasters
,
originalAmountPiasters
:
amountPiasters
,
calculationBasis
:
`Auto-calculated:
${
this
.
calculator
.
getSubCategoryName
(
dto
.
subCategory
)}
`
,
status
,
initiatedById
:
currentUser
.
id
,
initiatedByRole
:
currentUser
.
role
,
presetId
:
dto
.
presetId
||
null
,
payrollMonth
:
now
.
getMonth
()
+
1
,
payrollYear
:
now
.
getFullYear
(),
},
include
:
{
user
:
{
select
:
{
id
:
true
,
firstName
:
true
,
lastName
:
true
}
},
initiatedBy
:
{
select
:
{
id
:
true
,
firstName
:
true
,
lastName
:
true
}
},
},
});
this
.
logger
.
log
(
`Deduction
${
deduction
.
id
}
(
${
dto
.
subCategory
}
) created for
${
contractor
.
firstName
}
${
contractor
.
lastName
}
by
${
currentUser
.
email
}
:
${
amountPiasters
}
piasters`
,
);
return
deduction
;
}
async
findAll
(
filter
:
DeductionFilterDto
,
currentUser
:
RequestUser
):
Promise
<
PaginatedResult
<
any
>>
{
const
page
=
filter
.
page
||
1
;
const
limit
=
filter
.
limit
||
20
;
const
where
:
any
=
{};
// Permission filtering
if
(
currentUser
.
role
===
'CONTRACTOR'
)
{
where
.
userId
=
currentUser
.
id
;
}
else
if
(
currentUser
.
role
===
'TEAM_LEAD'
)
{
// PLs see deductions they initiated or for their team (counts only enforced at response level)
where
.
OR
=
[
{
initiatedById
:
currentUser
.
id
},
{
user
:
{
assignedProjectLeaderId
:
currentUser
.
id
}
},
];
}
if
(
filter
.
userId
)
where
.
userId
=
filter
.
userId
;
if
(
filter
.
category
)
where
.
category
=
filter
.
category
;
if
(
filter
.
subCategory
)
where
.
subCategory
=
filter
.
subCategory
;
if
(
filter
.
status
)
where
.
status
=
filter
.
status
;
if
(
filter
.
cardId
)
where
.
cardId
=
filter
.
cardId
;
if
(
filter
.
dateFrom
||
filter
.
dateTo
)
{
where
.
violationDate
=
{};
if
(
filter
.
dateFrom
)
where
.
violationDate
.
gte
=
new
Date
(
filter
.
dateFrom
);
if
(
filter
.
dateTo
)
where
.
violationDate
.
lte
=
new
Date
(
filter
.
dateTo
);
}
const
[
data
,
total
]
=
await
Promise
.
all
([
this
.
prisma
.
deduction
.
findMany
({
where
,
skip
:
getSkip
(
page
,
limit
),
take
:
limit
,
orderBy
:
{
createdAt
:
filter
.
sortOrder
||
'desc'
},
include
:
{
user
:
{
select
:
{
id
:
true
,
firstName
:
true
,
lastName
:
true
,
avatar
:
true
}
},
initiatedBy
:
{
select
:
{
id
:
true
,
firstName
:
true
,
lastName
:
true
}
},
reviewedBy
:
{
select
:
{
id
:
true
,
firstName
:
true
,
lastName
:
true
}
},
preset
:
{
select
:
{
id
:
true
,
name
:
true
}
},
},
}),
this
.
prisma
.
deduction
.
count
({
where
}),
]);
// For PLs, strip financial amounts — they only see counts
const
sanitized
=
data
.
map
((
d
:
any
)
=>
{
if
(
currentUser
.
role
===
'TEAM_LEAD'
)
{
return
{
id
:
d
.
id
,
userId
:
d
.
userId
,
user
:
d
.
user
,
category
:
d
.
category
,
subCategory
:
d
.
subCategory
,
status
:
d
.
status
,
violationDate
:
d
.
violationDate
,
description
:
d
.
description
,
createdAt
:
d
.
createdAt
,
// No amount fields for PL
};
}
return
d
;
});
return
buildPaginatedResponse
(
sanitized
,
total
,
{
page
,
limit
,
sortOrder
:
filter
.
sortOrder
||
'desc'
});
}
async
findById
(
id
:
string
,
currentUser
:
RequestUser
):
Promise
<
any
>
{
const
deduction
=
await
this
.
prisma
.
deduction
.
findUnique
({
where
:
{
id
},
include
:
{
user
:
{
select
:
{
id
:
true
,
firstName
:
true
,
lastName
:
true
,
avatar
:
true
}
},
initiatedBy
:
{
select
:
{
id
:
true
,
firstName
:
true
,
lastName
:
true
}
},
reviewedBy
:
{
select
:
{
id
:
true
,
firstName
:
true
,
lastName
:
true
}
},
card
:
{
select
:
{
id
:
true
,
cardNumber
:
true
,
title
:
true
}
},
preset
:
{
select
:
{
id
:
true
,
name
:
true
}
},
},
});
if
(
!
deduction
)
throw
new
NotFoundException
(
'Deduction not found'
);
// Contractors can only see their own
if
(
currentUser
.
role
===
'CONTRACTOR'
&&
deduction
.
userId
!==
currentUser
.
id
)
{
throw
new
ForbiddenException
(
'You can only view your own deductions'
);
}
return
deduction
;
}
async
acknowledge
(
id
:
string
,
currentUser
:
RequestUser
):
Promise
<
any
>
{
const
deduction
=
await
this
.
prisma
.
deduction
.
findUnique
({
where
:
{
id
}
});
if
(
!
deduction
)
throw
new
NotFoundException
(
'Deduction not found'
);
if
(
deduction
.
userId
!==
currentUser
.
id
)
{
throw
new
ForbiddenException
(
'You can only acknowledge your own deductions'
);
}
if
(
deduction
.
status
!==
'PENDING_ACKNOWLEDGMENT'
)
{
throw
new
BadRequestException
(
'This deduction is not pending acknowledgment'
);
}
const
updated
=
await
this
.
prisma
.
deduction
.
update
({
where
:
{
id
},
data
:
{
acknowledgedAt
:
new
Date
(),
acknowledgedById
:
currentUser
.
id
,
status
:
'PENDING_RESPONSE'
,
},
});
this
.
logger
.
log
(
`Deduction
${
id
}
acknowledged by contractor
${
currentUser
.
id
}
`
);
return
updated
;
}
async
respond
(
id
:
string
,
dto
:
RespondDeductionDto
,
currentUser
:
RequestUser
):
Promise
<
any
>
{
const
deduction
=
await
this
.
prisma
.
deduction
.
findUnique
({
where
:
{
id
}
});
if
(
!
deduction
)
throw
new
NotFoundException
(
'Deduction not found'
);
if
(
deduction
.
userId
!==
currentUser
.
id
)
{
throw
new
ForbiddenException
(
'You can only respond to your own deductions'
);
}
if
(
deduction
.
status
!==
'PENDING_RESPONSE'
)
{
throw
new
BadRequestException
(
'This deduction is not pending response'
);
}
if
(
dto
.
responseType
===
'DISPUTE'
&&
(
!
dto
.
responseText
||
dto
.
responseText
.
length
<
100
))
{
throw
new
BadRequestException
(
'Dispute explanation must be at least 100 characters'
);
}
if
(
dto
.
responseType
===
'ACCEPT'
)
{
// Auto-apply the deduction
const
updated
=
await
this
.
prisma
.
deduction
.
update
({
where
:
{
id
},
data
:
{
responseType
:
'ACCEPT'
,
respondedAt
:
new
Date
(),
status
:
'UPHELD'
,
appliedAmountPiasters
:
deduction
.
amountPiasters
,
appliedAt
:
new
Date
(),
reviewDecision
:
'UPHELD'
,
reviewNotes
:
'Contractor accepted the deduction'
,
},
});
// Push HUD update
try
{
await
this
.
hudService
.
pushDeductionApplied
(
deduction
.
userId
,
deduction
.
subCategory
,
deduction
.
amountPiasters
,
);
}
catch
(
err
)
{
this
.
logger
.
warn
(
`Failed to push HUD update:
${
err
.
message
}
`
);
}
// Check 40% threshold
await
this
.
checkDeductionThreshold
(
deduction
.
userId
);
return
updated
;
}
// DISPUTE — goes to admin for review
return
this
.
prisma
.
deduction
.
update
({
where
:
{
id
},
data
:
{
responseType
:
'DISPUTE'
,
responseText
:
dto
.
responseText
,
responseEvidence
:
dto
.
responseEvidence
||
null
,
respondedAt
:
new
Date
(),
// Status stays PENDING_RESPONSE — admin needs to review
},
});
}
async
reviewAdminDraft
(
id
:
string
,
decision
:
string
,
currentUser
:
RequestUser
):
Promise
<
any
>
{
if
(
currentUser
.
role
!==
'SUPER_ADMIN'
&&
currentUser
.
role
!==
'ADMIN'
)
{
throw
new
ForbiddenException
(
'Only Super Admin and Admin can review deduction drafts'
);
}
const
deduction
=
await
this
.
prisma
.
deduction
.
findUnique
({
where
:
{
id
}
});
if
(
!
deduction
)
throw
new
NotFoundException
(
'Deduction not found'
);
if
(
deduction
.
status
!==
'PENDING_ADMIN_REVIEW'
)
{
throw
new
BadRequestException
(
'This deduction is not pending admin review'
);
}
if
(
decision
===
'APPROVE'
)
{
return
this
.
prisma
.
deduction
.
update
({
where
:
{
id
},
data
:
{
status
:
'PENDING_ACKNOWLEDGMENT'
},
});
}
if
(
decision
===
'REJECT'
)
{
return
this
.
prisma
.
deduction
.
update
({
where
:
{
id
},
data
:
{
status
:
'CANCELLED'
,
reviewNotes
:
'Rejected by admin during draft review'
},
});
}
throw
new
BadRequestException
(
'Decision must be APPROVE or REJECT'
);
}
async
review
(
id
:
string
,
dto
:
ReviewDeductionDto
,
currentUser
:
RequestUser
):
Promise
<
any
>
{
if
(
currentUser
.
role
!==
'SUPER_ADMIN'
&&
currentUser
.
role
!==
'ADMIN'
)
{
throw
new
ForbiddenException
(
'Only Super Admin and Admin can review deductions'
);
}
const
deduction
=
await
this
.
prisma
.
deduction
.
findUnique
({
where
:
{
id
}
});
if
(
!
deduction
)
throw
new
NotFoundException
(
'Deduction not found'
);
const
validStatuses
=
[
'PENDING_RESPONSE'
,
'PENDING_ACKNOWLEDGMENT'
];
if
(
!
validStatuses
.
includes
(
deduction
.
status
)
&&
deduction
.
responseType
!==
'DISPUTE'
)
{
throw
new
BadRequestException
(
'This deduction cannot be reviewed in its current state'
);
}
let
appliedAmount
=
0
;
let
newStatus
=
''
;
switch
(
dto
.
decision
)
{
case
'UPHELD'
:
appliedAmount
=
deduction
.
amountPiasters
;
newStatus
=
'UPHELD'
;
break
;
case
'REDUCED'
:
if
(
!
dto
.
reducedAmountPiasters
||
dto
.
reducedAmountPiasters
<=
0
)
{
throw
new
BadRequestException
(
'Reduced amount must be provided and greater than 0'
);
}
if
(
dto
.
reducedAmountPiasters
>=
deduction
.
amountPiasters
)
{
throw
new
BadRequestException
(
'Reduced amount must be less than original amount'
);
}
appliedAmount
=
dto
.
reducedAmountPiasters
;
newStatus
=
'REDUCED'
;
break
;
case
'DISMISSED'
:
appliedAmount
=
0
;
newStatus
=
'DISMISSED'
;
break
;
default
:
throw
new
BadRequestException
(
'Decision must be UPHELD, REDUCED, or DISMISSED'
);
}
const
updated
=
await
this
.
prisma
.
deduction
.
update
({
where
:
{
id
},
data
:
{
reviewDecision
:
dto
.
decision
,
reviewNotes
:
dto
.
reviewNotes
||
null
,
reviewedById
:
currentUser
.
id
,
reviewedAt
:
new
Date
(),
reducedAmountPiasters
:
dto
.
decision
===
'REDUCED'
?
dto
.
reducedAmountPiasters
:
null
,
status
:
newStatus
,
appliedAmountPiasters
:
appliedAmount
>
0
?
appliedAmount
:
null
,
appliedAt
:
appliedAmount
>
0
?
new
Date
()
:
null
,
},
});
// Push HUD update if deduction was applied
if
(
appliedAmount
>
0
)
{
try
{
await
this
.
hudService
.
pushDeductionApplied
(
deduction
.
userId
,
deduction
.
subCategory
,
appliedAmount
,
);
}
catch
(
err
)
{
this
.
logger
.
warn
(
`Failed to push HUD update:
${
err
.
message
}
`
);
}
await
this
.
checkDeductionThreshold
(
deduction
.
userId
);
}
this
.
logger
.
log
(
`Deduction
${
id
}
reviewed:
${
dto
.
decision
}
(
${
appliedAmount
}
piasters) by
${
currentUser
.
email
}
`
,
);
return
updated
;
}
async
update
(
id
:
string
,
data
:
any
,
currentUser
:
RequestUser
):
Promise
<
any
>
{
if
(
currentUser
.
role
!==
'SUPER_ADMIN'
)
{
throw
new
ForbiddenException
(
'Only Super Admin can edit deductions after decision'
);
}
const
deduction
=
await
this
.
prisma
.
deduction
.
findUnique
({
where
:
{
id
}
});
if
(
!
deduction
)
throw
new
NotFoundException
(
'Deduction not found'
);
const
updateData
:
any
=
{};
if
(
data
.
amountPiasters
!==
undefined
)
updateData
.
amountPiasters
=
data
.
amountPiasters
;
if
(
data
.
appliedAmountPiasters
!==
undefined
)
updateData
.
appliedAmountPiasters
=
data
.
appliedAmountPiasters
;
if
(
data
.
description
!==
undefined
)
updateData
.
description
=
data
.
description
;
if
(
data
.
category
!==
undefined
)
updateData
.
category
=
data
.
category
;
if
(
data
.
subCategory
!==
undefined
)
updateData
.
subCategory
=
data
.
subCategory
;
if
(
data
.
status
!==
undefined
)
updateData
.
status
=
data
.
status
;
const
updated
=
await
this
.
prisma
.
deduction
.
update
({
where
:
{
id
},
data
:
updateData
,
});
// If amount changed and was applied, push HUD update
if
(
data
.
appliedAmountPiasters
!==
undefined
)
{
try
{
await
this
.
hudService
.
pushHudUpdate
(
deduction
.
userId
);
}
catch
(
err
)
{
this
.
logger
.
warn
(
`Failed to push HUD update:
${
err
.
message
}
`
);
}
}
return
updated
;
}
async
delete
(
id
:
string
,
currentUser
:
RequestUser
):
Promise
<
void
>
{
if
(
currentUser
.
role
!==
'SUPER_ADMIN'
)
{
throw
new
ForbiddenException
(
'Only Super Admin can delete deductions'
);
}
const
deduction
=
await
this
.
prisma
.
deduction
.
findUnique
({
where
:
{
id
}
});
if
(
!
deduction
)
throw
new
NotFoundException
(
'Deduction not found'
);
const
wasApplied
=
deduction
.
appliedAmountPiasters
&&
deduction
.
appliedAmountPiasters
>
0
;
await
this
.
prisma
.
deduction
.
delete
({
where
:
{
id
}
});
// If the deduction was applied, push HUD update to restore salary
if
(
wasApplied
)
{
try
{
await
this
.
hudService
.
pushHudUpdate
(
deduction
.
userId
);
}
catch
(
err
)
{
this
.
logger
.
warn
(
`Failed to push HUD update after deduction deletion:
${
err
.
message
}
`
);
}
}
this
.
logger
.
log
(
`Deduction
${
id
}
deleted by
${
currentUser
.
email
}
`
);
}
async
autoApplyExpired
(
deductionId
:
string
):
Promise
<
void
>
{
const
deduction
=
await
this
.
prisma
.
deduction
.
findUnique
({
where
:
{
id
:
deductionId
}
});
if
(
!
deduction
)
return
;
if
(
deduction
.
status
!==
'PENDING_RESPONSE'
)
return
;
if
(
deduction
.
respondedAt
)
return
;
// Already responded
await
this
.
prisma
.
deduction
.
update
({
where
:
{
id
:
deductionId
},
data
:
{
status
:
'AUTO_APPLIED'
,
appliedAmountPiasters
:
deduction
.
amountPiasters
,
appliedAt
:
new
Date
(),
autoAppliedAt
:
new
Date
(),
reviewDecision
:
'UPHELD'
,
reviewNotes
:
'Auto-applied: contractor did not respond within the response window'
,
},
});
try
{
await
this
.
hudService
.
pushDeductionApplied
(
deduction
.
userId
,
deduction
.
subCategory
,
deduction
.
amountPiasters
,
);
}
catch
(
err
)
{
this
.
logger
.
warn
(
`Failed to push HUD for auto-applied deduction:
${
err
.
message
}
`
);
}
await
this
.
checkDeductionThreshold
(
deduction
.
userId
);
this
.
logger
.
log
(
`Deduction
${
deductionId
}
auto-applied for contractor
${
deduction
.
userId
}
`
);
}
private
async
checkDeductionThreshold
(
userId
:
string
):
Promise
<
void
>
{
const
now
=
new
Date
();
const
month
=
now
.
getMonth
()
+
1
;
const
year
=
now
.
getFullYear
();
try
{
const
liveSalary
=
await
this
.
prisma
.
user
.
findUnique
({
where
:
{
id
:
userId
},
select
:
{
actualSalaryPiasters
:
true
,
baseSalaryPiasters
:
true
},
});
if
(
!
liveSalary
)
return
;
const
actualSalary
=
liveSalary
.
actualSalaryPiasters
||
liveSalary
.
baseSalaryPiasters
||
0
;
if
(
actualSalary
<=
0
)
return
;
const
totalDeductions
=
await
this
.
prisma
.
deduction
.
aggregate
({
where
:
{
userId
,
payrollMonth
:
month
,
payrollYear
:
year
,
status
:
{
in
:
[
'UPHELD'
,
'REDUCED'
,
'AUTO_APPLIED'
]
},
appliedAmountPiasters
:
{
not
:
null
},
},
_sum
:
{
appliedAmountPiasters
:
true
},
});
const
totalAmount
=
totalDeductions
.
_sum
.
appliedAmountPiasters
||
0
;
const
percentage
=
(
totalAmount
/
actualSalary
)
*
100
;
if
(
percentage
>=
40
)
{
this
.
logger
.
warn
(
`⚠️ THRESHOLD ALERT: Contractor
${
userId
}
has reached
${
percentage
.
toFixed
(
1
)}
% deduction threshold (
${
totalAmount
}
/
${
actualSalary
}
piasters)`
,
);
// TODO: When Notifications module is built (Phase 1E):
// - Send blocking notification to contractor
// - Send notification to Super Admin
// - Auto-recommend PIP
}
}
catch
(
err
)
{
this
.
logger
.
error
(
`Failed to check deduction threshold:
${
err
.
message
}
`
);
}
}
}
\ No newline at end of file
backend/src/modules/deductions/dto/create-deduction.dto.ts
0 → 100644
View file @
fcbd2e22
import
{
IsString
,
IsOptional
,
IsInt
,
IsDateString
,
Min
,
MinLength
,
IsArray
}
from
'class-validator'
;
export
class
CreateDeductionDto
{
@
IsString
()
userId
:
string
;
@
IsString
()
category
:
string
;
// A, B, C, D
@
IsString
()
subCategory
:
string
;
// A1-A5, B1-B4, C1-C4, D1-D4
@
IsOptional
()
@
IsString
()
cardId
?:
string
;
@
IsOptional
()
@
IsString
()
reportId
?:
string
;
@
IsDateString
()
violationDate
:
string
;
@
IsString
()
@
MinLength
(
100
,
{
message
:
'Description must be at least 100 characters'
})
description
:
string
;
@
IsOptional
()
evidence
?:
any
;
// JSON array
@
IsOptional
()
@
IsInt
()
@
Min
(
0
)
amountPiasters
?:
number
;
// If not provided, auto-calculated
@
IsOptional
()
@
IsString
()
presetId
?:
string
;
}
\ No newline at end of file
backend/src/modules/deductions/dto/deduction-filter.dto.ts
0 → 100644
View file @
fcbd2e22
import
{
IsOptional
,
IsString
,
IsDateString
}
from
'class-validator'
;
import
{
PaginationDto
}
from
'../../../common/dto/pagination.dto'
;
export
class
DeductionFilterDto
extends
PaginationDto
{
@
IsOptional
()
@
IsString
()
userId
?:
string
;
@
IsOptional
()
@
IsString
()
category
?:
string
;
@
IsOptional
()
@
IsString
()
subCategory
?:
string
;
@
IsOptional
()
@
IsString
()
status
?:
string
;
@
IsOptional
()
@
IsDateString
()
dateFrom
?:
string
;
@
IsOptional
()
@
IsDateString
()
dateTo
?:
string
;
@
IsOptional
()
@
IsString
()
cardId
?:
string
;
}
\ No newline at end of file
backend/src/modules/deductions/dto/respond-deduction.dto.ts
0 → 100644
View file @
fcbd2e22
import
{
IsString
,
IsOptional
,
MinLength
}
from
'class-validator'
;
export
class
RespondDeductionDto
{
@
IsString
()
responseType
:
string
;
// ACCEPT, DISPUTE
@
IsOptional
()
@
IsString
()
@
MinLength
(
100
,
{
message
:
'Dispute explanation must be at least 100 characters'
})
responseText
?:
string
;
@
IsOptional
()
responseEvidence
?:
any
;
}
\ No newline at end of file
backend/src/modules/deductions/dto/review-deduction.dto.ts
0 → 100644
View file @
fcbd2e22
import
{
IsString
,
IsOptional
,
IsInt
,
Min
}
from
'class-validator'
;
export
class
ReviewDeductionDto
{
@
IsString
()
decision
:
string
;
// UPHELD, REDUCED, DISMISSED
@
IsOptional
()
@
IsString
()
reviewNotes
?:
string
;
@
IsOptional
()
@
IsInt
()
@
Min
(
0
)
reducedAmountPiasters
?:
number
;
}
\ No newline at end of file
backend/src/modules/hud/hud.gateway.ts
0 → 100644
View file @
fcbd2e22
import
{
WebSocketGateway
,
WebSocketServer
,
SubscribeMessage
,
OnGatewayConnection
,
OnGatewayDisconnect
,
ConnectedSocket
,
MessageBody
,
}
from
'@nestjs/websockets'
;
import
{
Server
,
Socket
}
from
'socket.io'
;
import
{
Logger
,
UseGuards
}
from
'@nestjs/common'
;
import
{
JwtService
}
from
'@nestjs/jwt'
;
import
{
ConfigService
}
from
'@nestjs/config'
;
import
{
PrismaService
}
from
'../../prisma/prisma.service'
;
@
WebSocketGateway
({
namespace
:
'/hud'
,
cors
:
{
origin
:
'*'
,
credentials
:
true
},
})
export
class
HudGateway
implements
OnGatewayConnection
,
OnGatewayDisconnect
{
@
WebSocketServer
()
server
:
Server
;
private
readonly
logger
=
new
Logger
(
HudGateway
.
name
);
constructor
(
private
readonly
jwtService
:
JwtService
,
private
readonly
configService
:
ConfigService
,
private
readonly
prisma
:
PrismaService
,
)
{}
async
handleConnection
(
client
:
Socket
):
Promise
<
void
>
{
try
{
const
token
=
client
.
handshake
.
auth
?.
token
||
client
.
handshake
.
headers
?.
authorization
?.
replace
(
'Bearer '
,
''
);
if
(
!
token
)
{
client
.
disconnect
();
return
;
}
const
payload
=
this
.
jwtService
.
verify
(
token
,
{
secret
:
this
.
configService
.
get
<
string
>
(
'jwt.secret'
),
});
const
user
=
await
this
.
prisma
.
user
.
findUnique
({
where
:
{
id
:
payload
.
sub
},
select
:
{
id
:
true
,
role
:
true
,
status
:
true
},
});
if
(
!
user
||
user
.
status
===
'OFFBOARDED'
)
{
client
.
disconnect
();
return
;
}
(
client
as
any
).
userId
=
user
.
id
;
(
client
as
any
).
userRole
=
user
.
role
;
// Auto-join the user's HUD room
client
.
join
(
`hud:
${
user
.
id
}
`
);
this
.
logger
.
log
(
`HUD client connected:
${
user
.
id
}
`
);
}
catch
(
err
)
{
this
.
logger
.
warn
(
`HUD connection failed:
${
err
.
message
}
`
);
client
.
disconnect
();
}
}
handleDisconnect
(
client
:
Socket
):
void
{
const
userId
=
(
client
as
any
).
userId
;
if
(
userId
)
{
this
.
logger
.
log
(
`HUD client disconnected:
${
userId
}
`
);
}
}
@
SubscribeMessage
(
'hud:subscribe'
)
handleSubscribe
(
@
ConnectedSocket
()
client
:
Socket
,
@
MessageBody
()
data
:
{
contractorId
:
string
},
):
void
{
const
userId
=
(
client
as
any
).
userId
;
const
role
=
(
client
as
any
).
userRole
;
// Contractors can only subscribe to their own HUD
if
(
role
===
'CONTRACTOR'
&&
data
.
contractorId
!==
userId
)
{
client
.
emit
(
'error'
,
{
message
:
'Cannot subscribe to another contractor
\'
s HUD'
});
return
;
}
client
.
join
(
`hud:
${
data
.
contractorId
}
`
);
}
@
SubscribeMessage
(
'hud:unsubscribe'
)
handleUnsubscribe
(
@
ConnectedSocket
()
client
:
Socket
,
@
MessageBody
()
data
:
{
contractorId
:
string
},
):
void
{
client
.
leave
(
`hud:
${
data
.
contractorId
}
`
);
}
sendHudUpdate
(
contractorId
:
string
,
data
:
any
):
void
{
this
.
server
.
to
(
`hud:
${
contractorId
}
`
).
emit
(
'hud:update'
,
data
);
}
sendBountyEarned
(
contractorId
:
string
,
data
:
any
):
void
{
this
.
server
.
to
(
`hud:
${
contractorId
}
`
).
emit
(
'hud:bounty_earned'
,
data
);
}
sendDeductionApplied
(
contractorId
:
string
,
data
:
any
):
void
{
this
.
server
.
to
(
`hud:
${
contractorId
}
`
).
emit
(
'hud:deduction_applied'
,
data
);
}
sendAdjustmentApplied
(
contractorId
:
string
,
data
:
any
):
void
{
this
.
server
.
to
(
`hud:
${
contractorId
}
`
).
emit
(
'hud:adjustment_applied'
,
data
);
}
sendSalaryChanged
(
contractorId
:
string
,
data
:
any
):
void
{
this
.
server
.
to
(
`hud:
${
contractorId
}
`
).
emit
(
'hud:salary_changed'
,
data
);
}
}
\ No newline at end of file
backend/src/modules/hud/hud.module.ts
0 → 100644
View file @
fcbd2e22
import
{
Module
}
from
'@nestjs/common'
;
import
{
JwtModule
}
from
'@nestjs/jwt'
;
import
{
ConfigModule
}
from
'@nestjs/config'
;
import
{
HudGateway
}
from
'./hud.gateway'
;
import
{
HudService
}
from
'./hud.service'
;
import
{
SalaryModule
}
from
'../salary/salary.module'
;
@
Module
({
imports
:
[
SalaryModule
,
JwtModule
,
ConfigModule
],
providers
:
[
HudGateway
,
HudService
],
exports
:
[
HudService
],
})
export
class
HudModule
{}
\ No newline at end of file
backend/src/modules/hud/hud.service.ts
0 → 100644
View file @
fcbd2e22
import
{
Injectable
,
Logger
}
from
'@nestjs/common'
;
import
{
SalaryService
,
LiveSalaryData
}
from
'../salary/salary.service'
;
import
{
HudGateway
}
from
'./hud.gateway'
;
@
Injectable
()
export
class
HudService
{
private
readonly
logger
=
new
Logger
(
HudService
.
name
);
constructor
(
private
readonly
salaryService
:
SalaryService
,
private
readonly
hudGateway
:
HudGateway
,
)
{}
async
pushHudUpdate
(
contractorId
:
string
):
Promise
<
void
>
{
try
{
const
now
=
new
Date
();
const
liveSalary
=
await
this
.
salaryService
.
calculateLiveSalary
(
contractorId
,
now
.
getMonth
()
+
1
,
now
.
getFullYear
(),
);
const
streak
=
await
this
.
salaryService
.
getStreakData
(
contractorId
);
const
health
=
await
this
.
salaryService
.
getHealthStatus
(
contractorId
,
now
.
getMonth
()
+
1
,
now
.
getFullYear
(),
);
this
.
hudGateway
.
sendHudUpdate
(
contractorId
,
{
salary
:
liveSalary
,
streak
,
health
,
timestamp
:
new
Date
().
toISOString
(),
});
this
.
logger
.
debug
(
`HUD update pushed to contractor
${
contractorId
}
`
);
}
catch
(
err
)
{
this
.
logger
.
error
(
`Failed to push HUD update for
${
contractorId
}
:
${
err
.
message
}
`
);
}
}
async
pushBountyEarned
(
contractorId
:
string
,
cardTitle
:
string
,
amountPiasters
:
number
,
):
Promise
<
void
>
{
await
this
.
pushHudUpdate
(
contractorId
);
this
.
hudGateway
.
sendBountyEarned
(
contractorId
,
{
cardTitle
,
amountPiasters
,
timestamp
:
new
Date
().
toISOString
(),
});
}
async
pushDeductionApplied
(
contractorId
:
string
,
category
:
string
,
amountPiasters
:
number
,
):
Promise
<
void
>
{
await
this
.
pushHudUpdate
(
contractorId
);
this
.
hudGateway
.
sendDeductionApplied
(
contractorId
,
{
category
,
amountPiasters
,
timestamp
:
new
Date
().
toISOString
(),
});
}
async
pushAdjustmentApplied
(
contractorId
:
string
,
type
:
string
,
category
:
string
,
amountPiasters
:
number
,
):
Promise
<
void
>
{
await
this
.
pushHudUpdate
(
contractorId
);
this
.
hudGateway
.
sendAdjustmentApplied
(
contractorId
,
{
type
,
category
,
amountPiasters
,
timestamp
:
new
Date
().
toISOString
(),
});
}
async
pushSalaryChanged
(
contractorId
:
string
):
Promise
<
void
>
{
await
this
.
pushHudUpdate
(
contractorId
);
this
.
hudGateway
.
sendSalaryChanged
(
contractorId
,
{
timestamp
:
new
Date
().
toISOString
(),
});
}
}
\ No newline at end of file
backend/src/modules/payroll/dto/payroll-filter.dto.ts
0 → 100644
View file @
fcbd2e22
import
{
IsOptional
,
IsString
,
IsInt
}
from
'class-validator'
;
import
{
Type
}
from
'class-transformer'
;
import
{
PaginationDto
}
from
'../../../common/dto/pagination.dto'
;
export
class
PayrollFilterDto
extends
PaginationDto
{
@
IsOptional
()
@
Type
(()
=>
Number
)
@
IsInt
()
month
?:
number
;
@
IsOptional
()
@
Type
(()
=>
Number
)
@
IsInt
()
year
?:
number
;
@
IsOptional
()
@
IsString
()
status
?:
string
;
}
\ No newline at end of file
backend/src/modules/payroll/payroll.controller.ts
0 → 100644
View file @
fcbd2e22
import
{
Controller
,
Get
,
Post
,
Put
,
Param
,
Query
,
Body
,
HttpCode
,
HttpStatus
,
}
from
'@nestjs/common'
;
import
{
PayrollService
}
from
'./payroll.service'
;
import
{
PayrollFilterDto
}
from
'./dto/payroll-filter.dto'
;
import
{
CurrentUser
,
RequestUser
}
from
'../../common/decorators/current-user.decorator'
;
import
{
Roles
}
from
'../../common/decorators/roles.decorator'
;
@
Controller
(
'payroll'
)
export
class
PayrollController
{
constructor
(
private
readonly
payrollService
:
PayrollService
)
{}
@
Post
(
'calculate'
)
@
Roles
(
'SUPER_ADMIN'
,
'ADMIN'
)
async
calculate
(
@
Body
()
body
:
{
month
:
number
;
year
:
number
},
@
CurrentUser
()
user
:
RequestUser
,
)
{
return
this
.
payrollService
.
calculate
(
body
.
month
,
body
.
year
,
user
);
}
@
Get
()
@
Roles
(
'SUPER_ADMIN'
,
'ADMIN'
)
async
findAll
(@
Query
()
filter
:
PayrollFilterDto
,
@
CurrentUser
()
user
:
RequestUser
)
{
return
this
.
payrollService
.
findAll
(
filter
,
user
);
}
@
Get
(
'my'
)
async
getMyPayroll
(@
CurrentUser
()
user
:
RequestUser
)
{
return
this
.
payrollService
.
getMyPayroll
(
user
.
id
);
}
@
Get
(
':id'
)
@
Roles
(
'SUPER_ADMIN'
,
'ADMIN'
)
async
findById
(@
Param
(
'id'
)
id
:
string
,
@
CurrentUser
()
user
:
RequestUser
)
{
return
this
.
payrollService
.
findById
(
id
,
user
);
}
@
Post
(
':id/submit'
)
@
Roles
(
'SUPER_ADMIN'
,
'ADMIN'
)
@
HttpCode
(
HttpStatus
.
OK
)
async
submit
(@
Param
(
'id'
)
id
:
string
,
@
CurrentUser
()
user
:
RequestUser
)
{
return
this
.
payrollService
.
submit
(
id
,
user
);
}
@
Post
(
':id/approve'
)
@
Roles
(
'SUPER_ADMIN'
)
@
HttpCode
(
HttpStatus
.
OK
)
async
approve
(
@
Param
(
'id'
)
id
:
string
,
@
Body
(
'notes'
)
notes
:
string
,
@
CurrentUser
()
user
:
RequestUser
,
)
{
return
this
.
payrollService
.
approve
(
id
,
notes
,
user
);
}
@
Post
(
':id/reject'
)
@
Roles
(
'SUPER_ADMIN'
)
@
HttpCode
(
HttpStatus
.
OK
)
async
reject
(
@
Param
(
'id'
)
id
:
string
,
@
Body
(
'reason'
)
reason
:
string
,
@
CurrentUser
()
user
:
RequestUser
,
)
{
return
this
.
payrollService
.
reject
(
id
,
reason
,
user
);
}
@
Post
(
':id/processing'
)
@
Roles
(
'SUPER_ADMIN'
,
'ADMIN'
)
@
HttpCode
(
HttpStatus
.
OK
)
async
markProcessing
(@
Param
(
'id'
)
id
:
string
,
@
CurrentUser
()
user
:
RequestUser
)
{
return
this
.
payrollService
.
markProcessing
(
id
,
user
);
}
@
Post
(
':id/paid'
)
@
Roles
(
'SUPER_ADMIN'
,
'ADMIN'
)
@
HttpCode
(
HttpStatus
.
OK
)
async
markPaid
(@
Param
(
'id'
)
id
:
string
,
@
CurrentUser
()
user
:
RequestUser
)
{
return
this
.
payrollService
.
markPaid
(
id
,
user
);
}
}
\ No newline at end of file
backend/src/modules/payroll/payroll.module.ts
0 → 100644
View file @
fcbd2e22
import
{
Module
}
from
'@nestjs/common'
;
import
{
PayrollController
}
from
'./payroll.controller'
;
import
{
PayrollService
}
from
'./payroll.service'
;
import
{
SalaryModule
}
from
'../salary/salary.module'
;
@
Module
({
imports
:
[
SalaryModule
],
controllers
:
[
PayrollController
],
providers
:
[
PayrollService
],
exports
:
[
PayrollService
],
})
export
class
PayrollModule
{}
\ No newline at end of file
backend/src/modules/payroll/payroll.service.ts
0 → 100644
View file @
fcbd2e22
import
{
Injectable
,
NotFoundException
,
ForbiddenException
,
BadRequestException
,
ConflictException
,
Logger
,
}
from
'@nestjs/common'
;
import
{
PrismaService
}
from
'../../prisma/prisma.service'
;
import
{
SalaryService
}
from
'../salary/salary.service'
;
import
{
RequestUser
}
from
'../../common/decorators/current-user.decorator'
;
import
{
getSkip
,
buildPaginatedResponse
,
PaginatedResult
,
}
from
'../../common/utils/pagination.util'
;
import
{
getWorkingDaysInMonth
,
getScheduledDaysOfWeek
}
from
'../../common/utils/date.util'
;
import
{
calculateDailyRatePiasters
}
from
'../../common/utils/salary.util'
;
@
Injectable
()
export
class
PayrollService
{
private
readonly
logger
=
new
Logger
(
PayrollService
.
name
);
constructor
(
private
readonly
prisma
:
PrismaService
,
private
readonly
salaryService
:
SalaryService
,
)
{}
async
calculate
(
month
:
number
,
year
:
number
,
currentUser
:
RequestUser
):
Promise
<
any
>
{
if
(
currentUser
.
role
!==
'SUPER_ADMIN'
&&
currentUser
.
role
!==
'ADMIN'
)
{
throw
new
ForbiddenException
(
'Only Super Admin and Admin can calculate payroll'
);
}
// Check if payroll already exists for this month
let
payroll
=
await
this
.
prisma
.
payroll
.
findUnique
({
where
:
{
month_year
:
{
month
,
year
}
},
});
if
(
payroll
&&
!
[
'PENDING_CALCULATION'
,
'CALCULATED'
,
'REJECTED'
].
includes
(
payroll
.
status
))
{
throw
new
ConflictException
(
`Payroll for
${
month
}
/
${
year
}
is already in status "
${
payroll
.
status
}
". Cannot recalculate.`
,
);
}
// Create or update payroll record
if
(
!
payroll
)
{
payroll
=
await
this
.
prisma
.
payroll
.
create
({
data
:
{
month
,
year
,
status
:
'CALCULATED'
},
});
}
// Delete existing items if recalculating
await
this
.
prisma
.
payrollItem
.
deleteMany
({
where
:
{
payrollId
:
payroll
.
id
}
});
// Get all active contractors
const
contractors
=
await
this
.
prisma
.
user
.
findMany
({
where
:
{
role
:
'CONTRACTOR'
,
status
:
{
in
:
[
'ACTIVE'
,
'ON_PIP'
,
'SUSPENDED'
]
},
deletedAt
:
null
,
},
select
:
{
id
:
true
,
actualSalaryPiasters
:
true
,
baseSalaryPiasters
:
true
,
weeklySchedule
:
true
,
contractorType
:
true
,
},
});
let
totalGross
=
0
;
let
totalDeductions
=
0
;
let
totalBounties
=
0
;
let
totalAdjustments
=
0
;
let
totalNet
=
0
;
for
(
const
contractor
of
contractors
)
{
const
actualSalary
=
contractor
.
actualSalaryPiasters
||
contractor
.
baseSalaryPiasters
||
0
;
// Get schedule data
const
schedule
=
(
contractor
.
weeklySchedule
as
Record
<
string
,
string
>
)
||
{};
const
scheduledDays
=
getScheduledDaysOfWeek
(
schedule
);
const
expectedWorkingDays
=
getWorkingDaysInMonth
(
year
,
month
,
scheduledDays
);
const
dailyRate
=
calculateDailyRatePiasters
(
actualSalary
,
expectedWorkingDays
);
// Sum bounties
const
bounties
=
await
this
.
prisma
.
bountyPayout
.
aggregate
({
where
:
{
userId
:
contractor
.
id
,
payrollMonth
:
month
,
payrollYear
:
year
,
revokedAt
:
null
,
},
_sum
:
{
amountPiasters
:
true
},
});
const
bountyTotal
=
bounties
.
_sum
.
amountPiasters
||
0
;
// Sum deductions by category
const
deductionsByCategory
=
await
this
.
getDeductionsByCategory
(
contractor
.
id
,
month
,
year
);
const
totalCatA
=
deductionsByCategory
.
A
||
0
;
const
totalCatB
=
deductionsByCategory
.
B
||
0
;
const
totalCatC
=
deductionsByCategory
.
C
||
0
;
const
totalCatD
=
deductionsByCategory
.
D
||
0
;
const
deductionTotal
=
totalCatA
+
totalCatB
+
totalCatC
+
totalCatD
;
// Sum adjustments
const
positiveAdj
=
await
this
.
prisma
.
adjustment
.
aggregate
({
where
:
{
userId
:
contractor
.
id
,
effectiveMonth
:
month
,
effectiveYear
:
year
,
status
:
'APPROVED'
,
type
:
'POSITIVE'
,
},
_sum
:
{
amountPiasters
:
true
},
});
const
negativeAdj
=
await
this
.
prisma
.
adjustment
.
aggregate
({
where
:
{
userId
:
contractor
.
id
,
effectiveMonth
:
month
,
effectiveYear
:
year
,
status
:
'APPROVED'
,
type
:
'NEGATIVE'
,
},
_sum
:
{
amountPiasters
:
true
},
});
const
positiveAdjTotal
=
positiveAdj
.
_sum
.
amountPiasters
||
0
;
const
negativeAdjTotal
=
negativeAdj
.
_sum
.
amountPiasters
||
0
;
const
netPayable
=
actualSalary
+
bountyTotal
+
positiveAdjTotal
-
deductionTotal
-
negativeAdjTotal
;
await
this
.
prisma
.
payrollItem
.
create
({
data
:
{
payrollId
:
payroll
.
id
,
userId
:
contractor
.
id
,
actualSalaryPiasters
:
actualSalary
,
totalBountiesPiasters
:
bountyTotal
,
totalPositiveAdjPiasters
:
positiveAdjTotal
,
totalNegativeAdjPiasters
:
negativeAdjTotal
,
totalCatADeductions
:
totalCatA
,
totalCatBDeductions
:
totalCatB
,
totalCatCDeductions
:
totalCatC
,
totalCatDDeductions
:
totalCatD
,
totalDeductionsPiasters
:
deductionTotal
,
netPayablePiasters
:
netPayable
,
expectedWorkingDays
,
dailyRatePiasters
:
dailyRate
,
},
});
totalGross
+=
actualSalary
;
totalDeductions
+=
deductionTotal
;
totalBounties
+=
bountyTotal
;
totalAdjustments
+=
positiveAdjTotal
-
negativeAdjTotal
;
totalNet
+=
netPayable
;
}
// Update payroll totals
const
updated
=
await
this
.
prisma
.
payroll
.
update
({
where
:
{
id
:
payroll
.
id
},
data
:
{
status
:
'CALCULATED'
,
totalGrossPiasters
:
totalGross
,
totalDeductionsPiasters
:
totalDeductions
,
totalBountiesPiasters
:
totalBounties
,
totalAdjustmentsPiasters
:
totalAdjustments
,
totalNetPiasters
:
totalNet
,
contractorCount
:
contractors
.
length
,
calculatedAt
:
new
Date
(),
calculatedById
:
currentUser
.
id
,
},
include
:
{
items
:
{
include
:
{
user
:
{
select
:
{
id
:
true
,
firstName
:
true
,
lastName
:
true
}
},
},
},
},
});
this
.
logger
.
log
(
`Payroll for
${
month
}
/
${
year
}
calculated:
${
contractors
.
length
}
contractors,
${
totalNet
}
piasters net by
${
currentUser
.
email
}
`
,
);
return
updated
;
}
async
findAll
(
filter
:
any
,
currentUser
:
RequestUser
):
Promise
<
PaginatedResult
<
any
>>
{
const
page
=
filter
.
page
||
1
;
const
limit
=
filter
.
limit
||
20
;
const
where
:
any
=
{};
if
(
filter
.
month
)
where
.
month
=
filter
.
month
;
if
(
filter
.
year
)
where
.
year
=
filter
.
year
;
if
(
filter
.
status
)
where
.
status
=
filter
.
status
;
const
[
data
,
total
]
=
await
Promise
.
all
([
this
.
prisma
.
payroll
.
findMany
({
where
,
skip
:
getSkip
(
page
,
limit
),
take
:
limit
,
orderBy
:
[{
year
:
'desc'
},
{
month
:
'desc'
}],
}),
this
.
prisma
.
payroll
.
count
({
where
}),
]);
return
buildPaginatedResponse
(
data
,
total
,
{
page
,
limit
,
sortOrder
:
'desc'
});
}
async
findById
(
id
:
string
,
currentUser
:
RequestUser
):
Promise
<
any
>
{
const
payroll
=
await
this
.
prisma
.
payroll
.
findUnique
({
where
:
{
id
},
include
:
{
items
:
{
include
:
{
user
:
{
select
:
{
id
:
true
,
firstName
:
true
,
lastName
:
true
,
avatar
:
true
,
contractorType
:
true
}
},
},
orderBy
:
{
netPayablePiasters
:
'desc'
},
},
},
});
if
(
!
payroll
)
throw
new
NotFoundException
(
'Payroll record not found'
);
return
payroll
;
}
async
getMyPayroll
(
userId
:
string
,
month
?:
number
,
year
?:
number
):
Promise
<
any
[]
>
{
const
where
:
any
=
{
userId
};
if
(
month
)
where
.
payroll
=
{
...
where
.
payroll
,
month
};
if
(
year
)
where
.
payroll
=
{
...
where
.
payroll
,
year
};
return
this
.
prisma
.
payrollItem
.
findMany
({
where
,
include
:
{
payroll
:
{
select
:
{
month
:
true
,
year
:
true
,
status
:
true
,
paidAt
:
true
}
},
},
orderBy
:
{
createdAt
:
'desc'
},
take
:
24
,
});
}
async
submit
(
id
:
string
,
currentUser
:
RequestUser
):
Promise
<
any
>
{
if
(
currentUser
.
role
!==
'SUPER_ADMIN'
&&
currentUser
.
role
!==
'ADMIN'
)
{
throw
new
ForbiddenException
(
'Only Super Admin and Admin can submit payroll'
);
}
const
payroll
=
await
this
.
prisma
.
payroll
.
findUnique
({
where
:
{
id
}
});
if
(
!
payroll
)
throw
new
NotFoundException
(
'Payroll not found'
);
if
(
!
[
'CALCULATED'
,
'UNDER_REVIEW'
].
includes
(
payroll
.
status
))
{
throw
new
BadRequestException
(
`Cannot submit payroll in status "
${
payroll
.
status
}
"`
);
}
return
this
.
prisma
.
payroll
.
update
({
where
:
{
id
},
data
:
{
status
:
'SUBMITTED'
,
submittedById
:
currentUser
.
id
,
submittedAt
:
new
Date
(),
},
});
}
async
approve
(
id
:
string
,
notes
:
string
|
undefined
,
currentUser
:
RequestUser
):
Promise
<
any
>
{
if
(
currentUser
.
role
!==
'SUPER_ADMIN'
)
{
throw
new
ForbiddenException
(
'Only Super Admin can approve payroll'
);
}
const
payroll
=
await
this
.
prisma
.
payroll
.
findUnique
({
where
:
{
id
}
});
if
(
!
payroll
)
throw
new
NotFoundException
(
'Payroll not found'
);
if
(
payroll
.
status
!==
'SUBMITTED'
)
{
throw
new
BadRequestException
(
'Payroll must be submitted before approval'
);
}
return
this
.
prisma
.
payroll
.
update
({
where
:
{
id
},
data
:
{
status
:
'APPROVED'
,
approvedById
:
currentUser
.
id
,
approvedAt
:
new
Date
(),
approvalNotes
:
notes
||
null
,
},
});
}
async
reject
(
id
:
string
,
reason
:
string
,
currentUser
:
RequestUser
):
Promise
<
any
>
{
if
(
currentUser
.
role
!==
'SUPER_ADMIN'
)
{
throw
new
ForbiddenException
(
'Only Super Admin can reject payroll'
);
}
const
payroll
=
await
this
.
prisma
.
payroll
.
findUnique
({
where
:
{
id
}
});
if
(
!
payroll
)
throw
new
NotFoundException
(
'Payroll not found'
);
if
(
payroll
.
status
!==
'SUBMITTED'
)
{
throw
new
BadRequestException
(
'Payroll must be submitted to reject'
);
}
return
this
.
prisma
.
payroll
.
update
({
where
:
{
id
},
data
:
{
status
:
'REJECTED'
,
rejectedById
:
currentUser
.
id
,
rejectedAt
:
new
Date
(),
rejectionReason
:
reason
,
},
});
}
async
markProcessing
(
id
:
string
,
currentUser
:
RequestUser
):
Promise
<
any
>
{
if
(
currentUser
.
role
!==
'SUPER_ADMIN'
&&
currentUser
.
role
!==
'ADMIN'
)
{
throw
new
ForbiddenException
(
'Only Super Admin and Admin can mark payroll as processing'
);
}
const
payroll
=
await
this
.
prisma
.
payroll
.
findUnique
({
where
:
{
id
}
});
if
(
!
payroll
)
throw
new
NotFoundException
(
'Payroll not found'
);
if
(
payroll
.
status
!==
'APPROVED'
)
{
throw
new
BadRequestException
(
'Payroll must be approved to mark as processing'
);
}
return
this
.
prisma
.
payroll
.
update
({
where
:
{
id
},
data
:
{
status
:
'PROCESSING'
,
processingAt
:
new
Date
()
},
});
}
async
markPaid
(
id
:
string
,
currentUser
:
RequestUser
):
Promise
<
any
>
{
if
(
currentUser
.
role
!==
'SUPER_ADMIN'
&&
currentUser
.
role
!==
'ADMIN'
)
{
throw
new
ForbiddenException
(
'Only Super Admin and Admin can mark payroll as paid'
);
}
const
payroll
=
await
this
.
prisma
.
payroll
.
findUnique
({
where
:
{
id
}
});
if
(
!
payroll
)
throw
new
NotFoundException
(
'Payroll not found'
);
if
(
payroll
.
status
!==
'PROCESSING'
)
{
throw
new
BadRequestException
(
'Payroll must be processing to mark as paid'
);
}
return
this
.
prisma
.
payroll
.
update
({
where
:
{
id
},
data
:
{
status
:
'PAID'
,
paidAt
:
new
Date
(),
paidById
:
currentUser
.
id
},
});
}
private
async
getDeductionsByCategory
(
userId
:
string
,
month
:
number
,
year
:
number
,
):
Promise
<
Record
<
string
,
number
>>
{
const
result
:
Record
<
string
,
number
>
=
{
A
:
0
,
B
:
0
,
C
:
0
,
D
:
0
};
const
deductions
=
await
this
.
prisma
.
deduction
.
findMany
({
where
:
{
userId
,
payrollMonth
:
month
,
payrollYear
:
year
,
status
:
{
in
:
[
'UPHELD'
,
'REDUCED'
,
'AUTO_APPLIED'
]
},
appliedAmountPiasters
:
{
not
:
null
},
},
select
:
{
category
:
true
,
appliedAmountPiasters
:
true
},
});
for
(
const
d
of
deductions
)
{
if
(
result
[
d
.
category
]
!==
undefined
)
{
result
[
d
.
category
]
+=
d
.
appliedAmountPiasters
||
0
;
}
}
return
result
;
}
}
\ No newline at end of file
backend/src/modules/salary/salary.controller.ts
0 → 100644
View file @
fcbd2e22
import
{
Controller
,
Get
,
Param
,
Query
}
from
'@nestjs/common'
;
import
{
SalaryService
}
from
'./salary.service'
;
import
{
CurrentUser
,
RequestUser
}
from
'../../common/decorators/current-user.decorator'
;
import
{
Roles
}
from
'../../common/decorators/roles.decorator'
;
@
Controller
(
'salary'
)
export
class
SalaryController
{
constructor
(
private
readonly
salaryService
:
SalaryService
)
{}
@
Get
(
'hud'
)
async
getMyHud
(@
CurrentUser
()
user
:
RequestUser
)
{
const
now
=
new
Date
();
const
liveSalary
=
await
this
.
salaryService
.
calculateLiveSalary
(
user
.
id
,
now
.
getMonth
()
+
1
,
now
.
getFullYear
(),
);
const
streak
=
await
this
.
salaryService
.
getStreakData
(
user
.
id
);
const
health
=
await
this
.
salaryService
.
getHealthStatus
(
user
.
id
,
now
.
getMonth
()
+
1
,
now
.
getFullYear
(),
);
return
{
...
liveSalary
,
streak
,
health
};
}
@
Get
(
'hud/:userId'
)
@
Roles
(
'SUPER_ADMIN'
,
'ADMIN'
)
async
getContractorHud
(
@
Param
(
'userId'
)
userId
:
string
,
@
Query
(
'month'
)
month
?:
string
,
@
Query
(
'year'
)
year
?:
string
,
)
{
const
now
=
new
Date
();
const
m
=
month
?
parseInt
(
month
,
10
)
:
now
.
getMonth
()
+
1
;
const
y
=
year
?
parseInt
(
year
,
10
)
:
now
.
getFullYear
();
const
liveSalary
=
await
this
.
salaryService
.
calculateLiveSalary
(
userId
,
m
,
y
);
const
streak
=
await
this
.
salaryService
.
getStreakData
(
userId
);
const
health
=
await
this
.
salaryService
.
getHealthStatus
(
userId
,
m
,
y
);
return
{
...
liveSalary
,
streak
,
health
};
}
@
Get
(
'history'
)
async
getMyHistory
(@
CurrentUser
()
user
:
RequestUser
)
{
return
this
.
salaryService
.
getSalaryHistory
(
user
.
id
);
}
@
Get
(
'history/:userId'
)
@
Roles
(
'SUPER_ADMIN'
,
'ADMIN'
)
async
getContractorHistory
(@
Param
(
'userId'
)
userId
:
string
)
{
return
this
.
salaryService
.
getSalaryHistory
(
userId
);
}
@
Get
(
'daily-rate/:userId'
)
@
Roles
(
'SUPER_ADMIN'
,
'ADMIN'
)
async
getDailyRate
(
@
Param
(
'userId'
)
userId
:
string
,
@
Query
(
'month'
)
month
?:
string
,
@
Query
(
'year'
)
year
?:
string
,
)
{
const
now
=
new
Date
();
const
m
=
month
?
parseInt
(
month
,
10
)
:
now
.
getMonth
()
+
1
;
const
y
=
year
?
parseInt
(
year
,
10
)
:
now
.
getFullYear
();
const
dailyRate
=
await
this
.
salaryService
.
getDailyRate
(
userId
,
m
,
y
);
return
{
userId
,
month
:
m
,
year
:
y
,
dailyRatePiasters
:
dailyRate
};
}
}
\ No newline at end of file
backend/src/modules/salary/salary.module.ts
0 → 100644
View file @
fcbd2e22
import
{
Module
}
from
'@nestjs/common'
;
import
{
SalaryController
}
from
'./salary.controller'
;
import
{
SalaryService
}
from
'./salary.service'
;
@
Module
({
controllers
:
[
SalaryController
],
providers
:
[
SalaryService
],
exports
:
[
SalaryService
],
})
export
class
SalaryModule
{}
\ No newline at end of file
backend/src/modules/salary/salary.service.ts
0 → 100644
View file @
fcbd2e22
import
{
Injectable
,
NotFoundException
,
Logger
}
from
'@nestjs/common'
;
import
{
PrismaService
}
from
'../../prisma/prisma.service'
;
import
{
calculateDailyRatePiasters
,
piasterToEgp
,
formatEgp
,
}
from
'../../common/utils/salary.util'
;
import
{
getWorkingDaysInMonth
,
getScheduledDaysOfWeek
,
}
from
'../../common/utils/date.util'
;
export
interface
LiveSalaryData
{
contractorId
:
string
;
month
:
number
;
year
:
number
;
actualSalaryPiasters
:
number
;
baseSalaryPiasters
:
number
;
totalBountiesPiasters
:
number
;
totalPositiveAdjustmentsPiasters
:
number
;
totalDeductionsPiasters
:
number
;
totalNegativeAdjustmentsPiasters
:
number
;
netSalaryPiasters
:
number
;
healthPercentage
:
number
;
dailyRatePiasters
:
number
;
expectedWorkingDays
:
number
;
deductionCount
:
number
;
bountyCount
:
number
;
breakdown
:
{
bounties
:
Array
<
{
id
:
string
;
cardTitle
:
string
;
amountPiasters
:
number
;
paidAt
:
string
}
>
;
deductions
:
Array
<
{
id
:
string
;
category
:
string
;
subCategory
:
string
;
reason
:
string
;
amountPiasters
:
number
;
appliedAt
:
string
}
>
;
adjustments
:
Array
<
{
id
:
string
;
type
:
string
;
category
:
string
;
description
:
string
;
amountPiasters
:
number
;
approvedAt
:
string
}
>
;
};
}
export
interface
StreakData
{
currentStreak
:
number
;
bestStreak
:
number
;
rank
:
number
|
null
;
totalActiveContractors
:
number
;
}
@
Injectable
()
export
class
SalaryService
{
private
readonly
logger
=
new
Logger
(
SalaryService
.
name
);
constructor
(
private
readonly
prisma
:
PrismaService
)
{}
async
calculateLiveSalary
(
contractorId
:
string
,
month
:
number
,
year
:
number
):
Promise
<
LiveSalaryData
>
{
const
user
=
await
this
.
prisma
.
user
.
findFirst
({
where
:
{
id
:
contractorId
,
deletedAt
:
null
},
select
:
{
id
:
true
,
actualSalaryPiasters
:
true
,
baseSalaryPiasters
:
true
,
weeklySchedule
:
true
,
contractorType
:
true
,
},
});
if
(
!
user
)
{
throw
new
NotFoundException
(
'Contractor not found'
);
}
const
actualSalary
=
user
.
actualSalaryPiasters
||
user
.
baseSalaryPiasters
||
0
;
// Get expected working days
const
schedule
=
(
user
.
weeklySchedule
as
Record
<
string
,
string
>
)
||
{};
const
scheduledDays
=
getScheduledDaysOfWeek
(
schedule
);
// Get holidays for the month
const
holidays
=
await
this
.
getHolidaysForMonth
(
month
,
year
);
const
expectedWorkingDays
=
getWorkingDaysInMonth
(
year
,
month
,
scheduledDays
,
holidays
);
const
dailyRate
=
calculateDailyRatePiasters
(
actualSalary
,
expectedWorkingDays
);
// Get bounties for this month
const
bountyPayouts
=
await
this
.
prisma
.
bountyPayout
.
findMany
({
where
:
{
userId
:
contractorId
,
payrollMonth
:
month
,
payrollYear
:
year
,
revokedAt
:
null
,
},
orderBy
:
{
paidAt
:
'asc'
},
});
const
totalBounties
=
bountyPayouts
.
reduce
((
sum
,
b
)
=>
sum
+
b
.
amountPiasters
,
0
);
// Get applied deductions for this month
const
deductions
=
await
this
.
prisma
.
deduction
.
findMany
({
where
:
{
userId
:
contractorId
,
payrollMonth
:
month
,
payrollYear
:
year
,
status
:
{
in
:
[
'UPHELD'
,
'REDUCED'
,
'AUTO_APPLIED'
]
},
appliedAmountPiasters
:
{
not
:
null
},
},
orderBy
:
{
appliedAt
:
'asc'
},
});
const
totalDeductions
=
deductions
.
reduce
((
sum
,
d
)
=>
sum
+
(
d
.
appliedAmountPiasters
||
0
),
0
);
// Get approved adjustments for this month
const
adjustments
=
await
this
.
prisma
.
adjustment
.
findMany
({
where
:
{
userId
:
contractorId
,
effectiveMonth
:
month
,
effectiveYear
:
year
,
status
:
'APPROVED'
,
},
orderBy
:
{
approvedAt
:
'asc'
},
});
const
totalPositiveAdj
=
adjustments
.
filter
((
a
)
=>
a
.
type
===
'POSITIVE'
)
.
reduce
((
sum
,
a
)
=>
sum
+
a
.
amountPiasters
,
0
);
const
totalNegativeAdj
=
adjustments
.
filter
((
a
)
=>
a
.
type
===
'NEGATIVE'
)
.
reduce
((
sum
,
a
)
=>
sum
+
a
.
amountPiasters
,
0
);
const
netSalary
=
actualSalary
+
totalBounties
+
totalPositiveAdj
-
totalDeductions
-
totalNegativeAdj
;
const
healthPercentage
=
actualSalary
>
0
?
Math
.
round
((
netSalary
/
actualSalary
)
*
100
)
:
100
;
return
{
contractorId
,
month
,
year
,
actualSalaryPiasters
:
actualSalary
,
baseSalaryPiasters
:
user
.
baseSalaryPiasters
||
0
,
totalBountiesPiasters
:
totalBounties
,
totalPositiveAdjustmentsPiasters
:
totalPositiveAdj
,
totalDeductionsPiasters
:
totalDeductions
,
totalNegativeAdjustmentsPiasters
:
totalNegativeAdj
,
netSalaryPiasters
:
netSalary
,
healthPercentage
,
dailyRatePiasters
:
dailyRate
,
expectedWorkingDays
,
deductionCount
:
deductions
.
length
,
bountyCount
:
bountyPayouts
.
length
,
breakdown
:
{
bounties
:
bountyPayouts
.
map
((
b
)
=>
({
id
:
b
.
id
,
cardTitle
:
b
.
cardTitle
,
amountPiasters
:
b
.
amountPiasters
,
paidAt
:
b
.
paidAt
.
toISOString
(),
})),
deductions
:
deductions
.
map
((
d
)
=>
({
id
:
d
.
id
,
category
:
d
.
category
,
subCategory
:
d
.
subCategory
,
reason
:
d
.
description
.
substring
(
0
,
100
),
amountPiasters
:
d
.
appliedAmountPiasters
||
0
,
appliedAt
:
d
.
appliedAt
?.
toISOString
()
||
d
.
createdAt
.
toISOString
(),
})),
adjustments
:
adjustments
.
map
((
a
)
=>
({
id
:
a
.
id
,
type
:
a
.
type
,
category
:
a
.
category
,
description
:
a
.
description
.
substring
(
0
,
100
),
amountPiasters
:
a
.
type
===
'POSITIVE'
?
a
.
amountPiasters
:
-
a
.
amountPiasters
,
approvedAt
:
a
.
approvedAt
?.
toISOString
()
||
a
.
createdAt
.
toISOString
(),
})),
},
};
}
async
getDailyRate
(
contractorId
:
string
,
month
:
number
,
year
:
number
):
Promise
<
number
>
{
const
user
=
await
this
.
prisma
.
user
.
findFirst
({
where
:
{
id
:
contractorId
,
deletedAt
:
null
},
select
:
{
actualSalaryPiasters
:
true
,
baseSalaryPiasters
:
true
,
weeklySchedule
:
true
},
});
if
(
!
user
)
return
0
;
const
actualSalary
=
user
.
actualSalaryPiasters
||
user
.
baseSalaryPiasters
||
0
;
const
schedule
=
(
user
.
weeklySchedule
as
Record
<
string
,
string
>
)
||
{};
const
scheduledDays
=
getScheduledDaysOfWeek
(
schedule
);
const
holidays
=
await
this
.
getHolidaysForMonth
(
month
,
year
);
const
expectedWorkingDays
=
getWorkingDaysInMonth
(
year
,
month
,
scheduledDays
,
holidays
);
return
calculateDailyRatePiasters
(
actualSalary
,
expectedWorkingDays
);
}
async
getStreakData
(
contractorId
:
string
):
Promise
<
StreakData
>
{
// Streak calculation is simplified until the Reports module is built in Phase 2D.
// For now, return placeholder data based on available information.
const
user
=
await
this
.
prisma
.
user
.
findFirst
({
where
:
{
id
:
contractorId
,
deletedAt
:
null
},
select
:
{
id
:
true
,
status
:
true
},
});
if
(
!
user
)
{
return
{
currentStreak
:
0
,
bestStreak
:
0
,
rank
:
null
,
totalActiveContractors
:
0
};
}
// Count active contractors for ranking
const
totalActive
=
await
this
.
prisma
.
user
.
count
({
where
:
{
role
:
'CONTRACTOR'
,
status
:
'ACTIVE'
,
deletedAt
:
null
},
});
// TODO: Full streak calculation requires Reports module (Phase 2D)
// For now, return 0 streaks
return
{
currentStreak
:
0
,
bestStreak
:
0
,
rank
:
null
,
totalActiveContractors
:
totalActive
,
};
}
async
getHealthStatus
(
contractorId
:
string
,
month
:
number
,
year
:
number
):
Promise
<
string
>
{
const
liveSalary
=
await
this
.
calculateLiveSalary
(
contractorId
,
month
,
year
);
if
(
liveSalary
.
deductionCount
>
3
||
liveSalary
.
healthPercentage
<
60
)
{
return
'CRITICAL'
;
}
if
(
(
liveSalary
.
deductionCount
>=
2
&&
liveSalary
.
deductionCount
<=
3
)
||
(
liveSalary
.
healthPercentage
>=
60
&&
liveSalary
.
healthPercentage
<
80
)
)
{
return
'WARNING'
;
}
return
'HEALTHY'
;
}
async
getSalaryHistory
(
contractorId
:
string
):
Promise
<
any
[]
>
{
// Get payroll items for this contractor
const
items
=
await
this
.
prisma
.
payrollItem
.
findMany
({
where
:
{
userId
:
contractorId
},
include
:
{
payroll
:
{
select
:
{
month
:
true
,
year
:
true
,
status
:
true
,
paidAt
:
true
},
},
},
orderBy
:
{
createdAt
:
'desc'
},
take
:
24
,
// Last 2 years
});
return
items
.
map
((
item
)
=>
({
month
:
item
.
payroll
.
month
,
year
:
item
.
payroll
.
year
,
status
:
item
.
payroll
.
status
,
actualSalaryPiasters
:
item
.
actualSalaryPiasters
,
totalBountiesPiasters
:
item
.
totalBountiesPiasters
,
totalDeductionsPiasters
:
item
.
totalDeductionsPiasters
,
netPayablePiasters
:
item
.
netPayablePiasters
,
paidAt
:
item
.
payroll
.
paidAt
,
}));
}
private
async
getHolidaysForMonth
(
month
:
number
,
year
:
number
):
Promise
<
Date
[]
>
{
try
{
const
startDate
=
new
Date
(
year
,
month
-
1
,
1
);
const
endDate
=
new
Date
(
year
,
month
,
0
);
const
holidays
=
await
this
.
prisma
.
holiday
.
findMany
({
where
:
{
OR
:
[
{
startDate
:
{
gte
:
startDate
,
lte
:
endDate
}
},
{
endDate
:
{
gte
:
startDate
,
lte
:
endDate
}
},
],
},
});
const
holidayDates
:
Date
[]
=
[];
for
(
const
holiday
of
holidays
)
{
const
start
=
new
Date
(
holiday
.
startDate
);
const
end
=
new
Date
(
holiday
.
endDate
);
for
(
let
d
=
new
Date
(
start
);
d
<=
end
;
d
.
setDate
(
d
.
getDate
()
+
1
))
{
if
(
d
>=
startDate
&&
d
<=
endDate
)
{
holidayDates
.
push
(
new
Date
(
d
));
}
}
}
return
holidayDates
;
}
catch
{
// Holiday table may not exist yet
return
[];
}
}
}
\ No newline at end of file
prisma/schema-financial.prisma
0 → 100644
View file @
fcbd2e22
//
═══════════════════════════════════════════════════
//
FINANCIAL
MODELS
—
Phase
1
D
//
═══════════════════════════════════════════════════
model
Deduction
{
id
String
@
id
@
default
(
uuid
())
userId
String
user
User
@
relation
(
"UserDeductions"
,
fields
:
[
userId
],
references
:
[
id
],
onDelete
:
Restrict
)
category
String
//
A
,
B
,
C
,
D
subCategory
String
//
A1
,
A2
,
A3
,
A4
,
A5
,
B1
,
B2
,
B3
,
B4
,
C1
,
C2
,
C3
,
C4
,
D1
,
D2
,
D3
,
D4
cardId
String
?
card
Card
?
@
relation
(
"CardDeductions"
,
fields
:
[
cardId
],
references
:
[
id
],
onDelete
:
SetNull
)
reportId
String
?
//
No
direct
relation
to
Report
model
yet
—
will
be
added
in
Phase
2
D
violationDate
DateTime
description
String
evidence
Json
?
//
Array
of
file
paths
/
descriptions
amountPiasters
Int
originalAmountPiasters
Int
//
Before
any
reduction
calculationBasis
String
?
//
Description
of
how
amount
was
calculated
status
String
@
default
(
"DRAFT"
)
//
DRAFT
,
PENDING_ADMIN_REVIEW
,
PENDING_ACKNOWLEDGMENT
,
PENDING_RESPONSE
,
//
UPHELD
,
REDUCED
,
DISMISSED
,
AUTO_APPLIED
,
CANCELLED
//
Acknowledgment
acknowledgedAt
DateTime
?
acknowledgedById
String
?
//
Contractor
response
responseType
String
?
//
ACCEPT
,
DISPUTE
responseText
String
?
responseEvidence
Json
?
respondedAt
DateTime
?
//
Review
decision
reviewDecision
String
?
//
UPHELD
,
REDUCED
,
DISMISSED
reviewNotes
String
?
reviewedById
String
?
reviewedBy
User
?
@
relation
(
"DeductionReviewer"
,
fields
:
[
reviewedById
],
references
:
[
id
],
onDelete
:
SetNull
)
reviewedAt
DateTime
?
reducedAmountPiasters
Int
?
//
Auto
-
apply
tracking
autoApplyJobId
String
?
autoAppliedAt
DateTime
?
//
Who
initiated
initiatedById
String
initiatedBy
User
@
relation
(
"DeductionInitiator"
,
fields
:
[
initiatedById
],
references
:
[
id
],
onDelete
:
Restrict
)
initiatedByRole
String
//
Role
at
time
of
initiation
//
Final
applied
amount
(
after
review
)
appliedAmountPiasters
Int
?
appliedAt
DateTime
?
//
Payroll
link
payrollMonth
Int
?
payrollYear
Int
?
presetId
String
?
preset
DeductionPreset
?
@
relation
(
fields
:
[
presetId
],
references
:
[
id
],
onDelete
:
SetNull
)
createdAt
DateTime
@
default
(
now
())
updatedAt
DateTime
@
updatedAt
@@
index
([
userId
])
@@
index
([
status
])
@@
index
([
category
,
subCategory
])
@@
index
([
cardId
])
@@
index
([
payrollMonth
,
payrollYear
])
@@
index
([
createdAt
])
@@
index
([
initiatedById
])
}
model
DeductionPreset
{
id
String
@
id
@
default
(
uuid
())
name
String
@
unique
category
String
subCategory
String
description
String
calculationFormula
String
//
Human
-
readable
description
of
the
calculation
isActive
Boolean
@
default
(
true
)
createdById
String
createdBy
User
@
relation
(
"DeductionPresetCreator"
,
fields
:
[
createdById
],
references
:
[
id
],
onDelete
:
Restrict
)
deductions
Deduction
[]
createdAt
DateTime
@
default
(
now
())
updatedAt
DateTime
@
updatedAt
}
model
Adjustment
{
id
String
@
id
@
default
(
uuid
())
userId
String
user
User
@
relation
(
"UserAdjustments"
,
fields
:
[
userId
],
references
:
[
id
],
onDelete
:
Restrict
)
type
String
//
POSITIVE
,
NEGATIVE
category
String
//
ADVANCE
,
REIMBURSEMENT
,
BONUS
,
CORRECTION
,
LOAN
,
OTHER
amountPiasters
Int
description
String
effectiveMonth
Int
effectiveYear
Int
status
String
@
default
(
"PENDING_APPROVAL"
)
//
PENDING_APPROVAL
,
APPROVED
,
REJECTED
//
Approval
approvedById
String
?
approvedBy
User
?
@
relation
(
"AdjustmentApprover"
,
fields
:
[
approvedById
],
references
:
[
id
],
onDelete
:
SetNull
)
approvedAt
DateTime
?
rejectionReason
String
?
//
Who
created
createdById
String
createdBy
User
@
relation
(
"AdjustmentCreator"
,
fields
:
[
createdById
],
references
:
[
id
],
onDelete
:
Restrict
)
//
Payroll
link
payrollMonth
Int
?
payrollYear
Int
?
createdAt
DateTime
@
default
(
now
())
updatedAt
DateTime
@
updatedAt
@@
index
([
userId
])
@@
index
([
status
])
@@
index
([
effectiveMonth
,
effectiveYear
])
@@
index
([
createdById
])
@@
index
([
type
])
}
model
BountyPayout
{
id
String
@
id
@
default
(
uuid
())
cardId
String
card
Card
@
relation
(
"CardBountyPayouts"
,
fields
:
[
cardId
],
references
:
[
id
],
onDelete
:
Restrict
)
userId
String
user
User
@
relation
(
"UserBountyPayouts"
,
fields
:
[
userId
],
references
:
[
id
],
onDelete
:
Restrict
)
amountPiasters
Int
splitPercentage
Float
@
default
(
100
)
boardId
String
cardNumber
String
cardTitle
String
payrollMonth
Int
payrollYear
Int
paidAt
DateTime
@
default
(
now
())
revokedAt
DateTime
?
revokedById
String
?
revokeReason
String
?
createdAt
DateTime
@
default
(
now
())
@@
index
([
userId
])
@@
index
([
cardId
])
@@
index
([
payrollMonth
,
payrollYear
])
@@
index
([
paidAt
])
}
model
Payroll
{
id
String
@
id
@
default
(
uuid
())
month
Int
year
Int
status
String
@
default
(
"PENDING_CALCULATION"
)
//
PENDING_CALCULATION
,
CALCULATED
,
UNDER_REVIEW
,
SUBMITTED
,
APPROVED
,
REJECTED
,
PROCESSING
,
PAID
totalGrossPiasters
Int
@
default
(
0
)
totalDeductionsPiasters
Int
@
default
(
0
)
totalBountiesPiasters
Int
@
default
(
0
)
totalAdjustmentsPiasters
Int
@
default
(
0
)
totalNetPiasters
Int
@
default
(
0
)
contractorCount
Int
@
default
(
0
)
calculatedAt
DateTime
?
calculatedById
String
?
reviewedById
String
?
reviewedAt
DateTime
?
reviewNotes
String
?
submittedById
String
?
submittedAt
DateTime
?
approvedById
String
?
approvedAt
DateTime
?
approvalNotes
String
?
rejectedById
String
?
rejectedAt
DateTime
?
rejectionReason
String
?
processingAt
DateTime
?
paidAt
DateTime
?
paidById
String
?
items
PayrollItem
[]
createdAt
DateTime
@
default
(
now
())
updatedAt
DateTime
@
updatedAt
@@
unique
([
month
,
year
])
@@
index
([
status
])
}
model
PayrollItem
{
id
String
@
id
@
default
(
uuid
())
payrollId
String
payroll
Payroll
@
relation
(
fields
:
[
payrollId
],
references
:
[
id
],
onDelete
:
Cascade
)
userId
String
user
User
@
relation
(
"UserPayrollItems"
,
fields
:
[
userId
],
references
:
[
id
],
onDelete
:
Restrict
)
actualSalaryPiasters
Int
totalBountiesPiasters
Int
@
default
(
0
)
totalPositiveAdjPiasters
Int
@
default
(
0
)
totalNegativeAdjPiasters
Int
@
default
(
0
)
totalCatADeductions
Int
@
default
(
0
)
totalCatBDeductions
Int
@
default
(
0
)
totalCatCDeductions
Int
@
default
(
0
)
totalCatDDeductions
Int
@
default
(
0
)
totalDeductionsPiasters
Int
@
default
(
0
)
netPayablePiasters
Int
@
default
(
0
)
expectedWorkingDays
Int
@
default
(
0
)
actualWorkingDays
Int
@
default
(
0
)
dailyRatePiasters
Int
@
default
(
0
)
notes
String
?
overrideAmount
Int
?
overrideReason
String
?
overriddenById
String
?
createdAt
DateTime
@
default
(
now
())
updatedAt
DateTime
@
updatedAt
@@
unique
([
payrollId
,
userId
])
@@
index
([
userId
])
@@
index
([
payrollId
])
}
\ No newline at end of file
Write
Preview
Markdown
is supported
0%
Try again
or
attach a new file
Attach a file
Cancel
You are about to add
0
people
to the discussion. Proceed with caution.
Finish editing this message first!
Cancel
Please
register
or
sign in
to comment