Skip to content

Commit 4d8e6ef

Browse files
authored
Merge pull request #1295 from bcgov/dev
Dev
2 parents b533ce4 + 6af0d09 commit 4d8e6ef

8 files changed

Lines changed: 196 additions & 63 deletions

File tree

applications/Unity.GrantManager/modules/Unity.Payments/src/Unity.Payments.Application/Domain/Exceptions/ErrorConsts.cs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,5 +7,6 @@ public static class ErrorConsts
77
public const string ConfigurationExists = "Unity.Payments:Errors:ConfigurationExists";
88
public const string ConfigurationDoesNotExist = "Unity.Payments:Errors:ConfigurationDoesNotExist";
99
public const string InvalidAccountCodingField = "Unity.Payments:Errors:InvalidAccountCodingFiled";
10+
public const string L2ApproverRestriction = "Unity.Payments:Errors:L2ApproverRestriction";
1011
}
1112
}

applications/Unity.GrantManager/modules/Unity.Payments/src/Unity.Payments.Application/PaymentRequests/PaymentRequestAppService.cs

Lines changed: 31 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@
66
using System.Linq;
77
using System.Threading.Tasks;
88
using Unity.Payment.Shared;
9+
using Unity.Payments.Domain.Exceptions;
910
using Unity.Payments.Domain.PaymentConfigurations;
1011
using Unity.Payments.Domain.PaymentRequests;
1112
using Unity.Payments.Domain.Services;
@@ -95,7 +96,7 @@ public virtual async Task<List<PaymentRequestDto>> CreateAsync(List<CreatePaymen
9596
// referenceNumber + Chefs Confirmation ID + 6 digit sequence based on sequence number and index
9697
CreatePaymentRequestDto paymentRequestDto = paymentRequestItem.value;
9798
string referenceNumberPrefix = GenerateReferenceNumberPrefixAsync(paymentIdPrefix);
98-
string sequenceNumber = GenerateSequenceNumberAsync(nextSequenceNumber, paymentRequestItem.i);
99+
string sequenceNumber = GenerateSequenceNumberAsync(nextSequenceNumber, paymentRequestItem.i);
99100
string referenceNumber = GenerateReferenceNumberAsync(referenceNumberPrefix, sequenceNumber);
100101
string invoiceNumber = GenerateInvoiceNumberAsync(referenceNumberPrefix, paymentRequestDto.InvoiceNumber, sequenceNumber);
101102

@@ -138,13 +139,13 @@ private static string GenerateInvoiceNumberAsync(string referenceNumber, string
138139
return $"{referenceNumber}-{invoiceNumber}-{sequencePart}";
139140
}
140141

141-
private static string GenerateReferenceNumberAsync(string referenceNumber, string sequencePart)
142+
private static string GenerateReferenceNumberAsync(string referenceNumber, string sequencePart)
142143
{
143144
return $"{referenceNumber}-{sequencePart}";
144145
}
145146

146147

147-
private static string GenerateSequenceNumberAsync(int sequenceNumber, int index)
148+
private static string GenerateSequenceNumberAsync(int sequenceNumber, int index)
148149
{
149150
sequenceNumber = sequenceNumber + index;
150151
return sequenceNumber.ToString("D4");
@@ -185,6 +186,20 @@ public virtual async Task<List<PaymentRequestDto>> UpdateStatusAsync(List<Update
185186

186187
var paymentThreshold = await GetPaymentThresholdAsync();
187188

189+
// Check approval batches
190+
var approvalRequests = paymentRequests.Where(r => r.IsApprove).Select(x => x.PaymentRequestId).ToList();
191+
var approvalList = await _paymentRequestsRepository.GetListAsync(x => approvalRequests.Contains(x.Id), includeDetails: true);
192+
193+
// Rule AB#26693: Reject Payment Request update batch if violates L1 and L2 separation of duties
194+
if (approvalList.Any(
195+
x => x.Status == PaymentRequestStatus.L2Pending
196+
&& CurrentUser.Id == x.ExpenseApprovals.FirstOrDefault(y => y.Type == ExpenseApprovalType.Level1)?.DecisionUserId))
197+
{
198+
throw new BusinessException(
199+
code: ErrorConsts.L2ApproverRestriction,
200+
message: L[ErrorConsts.L2ApproverRestriction]);
201+
}
202+
188203
foreach (var dto in paymentRequests)
189204
{
190205
try
@@ -206,6 +221,7 @@ public virtual async Task<List<PaymentRequestDto>> UpdateStatusAsync(List<Update
206221

207222
return updatedPayments;
208223
}
224+
209225
private async Task<PaymentApprovalAction> DetermineTriggerActionAsync(
210226
UpdatePaymentStatusRequestDto dto,
211227
PaymentRequest payment,
@@ -216,7 +232,7 @@ private async Task<PaymentApprovalAction> DetermineTriggerActionAsync(
216232
return dto.IsApprove ? PaymentApprovalAction.L1Approve : PaymentApprovalAction.L1Decline;
217233
}
218234

219-
if (await CanPerformLevel2ActionAsync(payment))
235+
if (await CanPerformLevel2ActionAsync(payment, dto.IsApprove))
220236
{
221237
if (dto.IsApprove)
222238
{
@@ -241,9 +257,18 @@ private async Task<bool> CanPerformLevel1ActionAsync(PaymentRequestStatus status
241257
return await _permissionChecker.IsGrantedAsync(PaymentsPermissions.Payments.L1ApproveOrDecline) && level1Approvals.Contains(status);
242258
}
243259

244-
private async Task<bool> CanPerformLevel2ActionAsync(PaymentRequest payment)
260+
private async Task<bool> CanPerformLevel2ActionAsync(PaymentRequest payment, bool IsApprove)
245261
{
246262
List<PaymentRequestStatus> level2Approvals = new() { PaymentRequestStatus.L2Pending, PaymentRequestStatus.L2Declined };
263+
264+
// Rule AB#26693: Reject Payment Request update if violates L1 and L2 separation of duties
265+
var IsSameApprover = CurrentUser.Id == payment.ExpenseApprovals.FirstOrDefault(x => x.Type == ExpenseApprovalType.Level1)?.DecisionUserId;
266+
if (IsSameApprover && IsApprove)
267+
{
268+
throw new BusinessException(
269+
code: ErrorConsts.L2ApproverRestriction,
270+
message: L[ErrorConsts.L2ApproverRestriction]);
271+
}
247272
return await _permissionChecker.IsGrantedAsync(PaymentsPermissions.Payments.L2ApproveOrDecline) && level2Approvals.Contains(payment.Status);
248273
}
249274

@@ -380,6 +405,7 @@ public async Task<List<PaymentDetailsDto>> GetListByPaymentIdsAsync(List<Guid> p
380405
var payments = await paymentsQueryable
381406
.Where(e => paymentIds.Contains(e.Id))
382407
.Include(pr => pr.Site)
408+
.Include(x => x.ExpenseApprovals)
383409
.ToListAsync();
384410

385411
return ObjectMapper.Map<List<PaymentRequest>, List<PaymentDetailsDto>>(payments);

applications/Unity.GrantManager/modules/Unity.Payments/src/Unity.Payments.Shared/Localization/Payments/en.json

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@
99
"Unity.Payments:Errors:ConfigurationExists": "Configuration already exitst",
1010
"Unity.Payments:Errors:ConfigurationDoesNotExist": "Configuration does not exits",
1111
"Unity.Payments:Errors:InvalidAccountCodingFiled": "Invalid account coding field {field} : {length}",
12+
"Unity.Payments:Errors:L2ApproverRestriction": "You cannot approve one or more selected payments as you have already approved them as an L1 Approver.",
1213

1314
"Setting:Payments.ThresholdAmount.DisplayName": "Payment threshold amount",
1415
"Setting:Payments.ThresholdAmount.Description": "Payment threshold that triggers additional approvals and checks",
@@ -21,6 +22,9 @@
2122
"ApplicationPaymentRequest:SubmitButtonText": "Submit Payment Requests",
2223
"ApplicationPaymentRequest:CancelButtonText": "Cancel",
2324
"ApplicationPaymentRequest:Validations:RemainingAmountExceeded": "Cannot add a payment that exceeds the remaining amount of ",
25+
"ApplicationPaymentRequest:Validations:L2ApproverRestriction": "You cannot approve this payment as you have already approved it as an L1 Approver.",
26+
"ApplicationPaymentRequest:Validations:L2ApproverRestrictionBatch": "Highlighted payments were already approved with L1 permission. L1 and L2 approvers must be different individuals",
27+
2428

2529
"ApplicationPaymentTable:BatchNumber": "Batch Number",
2630
"ApplicationPaymentTable:TotalAmount": "Total Amount",

applications/Unity.GrantManager/modules/Unity.Payments/src/Unity.Payments.Web/Pages/PaymentApprovals/PaymentsApprovalModel.cs

Lines changed: 29 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,16 @@
1-
using System.ComponentModel.DataAnnotations;
2-
using System.ComponentModel;
1+
using Microsoft.Extensions.DependencyInjection;
2+
using Microsoft.Extensions.Localization;
33
using System;
44
using System.Collections.Generic;
5+
using System.ComponentModel;
6+
using System.ComponentModel.DataAnnotations;
57
using Unity.Payments.Enums;
8+
using Unity.Payments.Localization;
9+
using Volo.Abp.Users;
610

711
namespace Unity.Payments.Web.Pages.PaymentApprovals
812
{
9-
public class PaymentsApprovalModel
13+
public class PaymentsApprovalModel : IValidatableObject
1014
{
1115
[Required]
1216
public Guid Id { get; set; }
@@ -32,8 +36,6 @@ public class PaymentsApprovalModel
3236

3337
public bool isPermitted { get; set; }
3438

35-
public List<string> ErrorList { get; set; } = new List<string> { };
36-
3739
public bool IsL3ApprovalRequired { get; set; }
3840

3941
public PaymentRequestStatus ToStatus { get; set; }
@@ -42,5 +44,27 @@ public class PaymentsApprovalModel
4244

4345
public string ToStatusText { get; set; } = string.Empty;
4446

47+
public Guid? PreviousL1Approver { get; set; }
48+
49+
public bool IsApproval { get; set; }
50+
public bool IsValid { get; set; } = false;
51+
public Guid CurrentUser { get; set; }
52+
53+
public IEnumerable<ValidationResult> Validate(ValidationContext validationContext)
54+
{
55+
var currentUser = validationContext.GetRequiredService<ICurrentUser>();
56+
var localizer = validationContext.GetRequiredService<IStringLocalizer<PaymentsResource>>();
57+
58+
// Rule AB#26693: Reject Payment Request update batch if violates L1 and L2 separation of duties
59+
if (IsApproval
60+
&& Status == PaymentRequestStatus.L2Pending
61+
&& PreviousL1Approver == currentUser.Id)
62+
{
63+
yield return new ValidationResult(
64+
errorMessage: localizer["ApplicationPaymentRequest:Validations:L2ApproverRestriction"],
65+
memberNames: [nameof(PreviousL1Approver)]
66+
);
67+
}
68+
}
4569
}
4670
}

applications/Unity.GrantManager/modules/Unity.Payments/src/Unity.Payments.Web/Pages/PaymentApprovals/UpdatePaymentRequestStatus.cshtml

Lines changed: 64 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -14,8 +14,16 @@
1414
int uniqueIndex = 0;
1515
}
1616

17+
@functions
18+
{
19+
public static string GetContainerValidationState(bool isValid)
20+
{
21+
return isValid ? "single-payment" : "single-payment bg-danger-subtle border-danger-subtle";
22+
}
23+
}
24+
1725

18-
<form method="post" asp-page-handler="OnPostAsync" id="paymentRequestStatus">
26+
<form method="post" asp-page-handler="OnPostAsync" id="paymentRequestStatus" data-ajaxForm="true">
1927
<abp-modal size="ExtraLarge" id="payment-modal">
2028
@if (Model.IsApproval)
2129
{
@@ -32,7 +40,7 @@
3240
<abp-input id="PaymentThreshold" type="hidden" asp-for="PaymentThreshold" />
3341
<abp-input id="ApplicationCount" type="hidden" asp-for="PaymentGroupings.Count" />
3442

35-
@if (Model.PaymentGroupings.Count >= 1)
43+
@if (Model.PaymentGroupings.Count > 0)
3644
{
3745
@for (int k = 0; k < Model.PaymentGroupings.Count; k++)
3846
{
@@ -45,7 +53,7 @@
4553

4654
@for (int i = 0; i < Model.PaymentGroupings[k].Items.Count; i++)
4755
{
48-
<div id="@($"{@Model.PaymentGroupings[k].Items[i].Id}_container")" class="single-payment">
56+
<div id="@($"{@Model.PaymentGroupings[k].Items[i].Id}_container")" class="@GetContainerValidationState(Model.PaymentGroupings[k].Items[i].IsValid)">
4957
<input type="hidden" asp-for="@Model.PaymentGroupings[k].Items[i].Id" />
5058
<input type="hidden" asp-for="@Model.PaymentGroupings[k].Items[i].ReferenceNumber" />
5159
<input type="hidden" asp-for="@Model.PaymentGroupings[k].Items[i].Amount" />
@@ -57,9 +65,21 @@
5765
<input type="hidden" asp-for="@Model.PaymentGroupings[k].Items[i].isPermitted" />
5866
<input type="hidden" asp-for="@Model.PaymentGroupings[k].Items[i].IsL3ApprovalRequired" />
5967
<input type="hidden" asp-for="@Model.PaymentGroupings[k].Items[i].ToStatus" />
60-
<abp-row class="m-0 p-2">
61-
<abp-column size="_11" class="px-1"><h6 class="single-payment-card-application-name">@Model.PaymentGroupings[k].Items[i].ApplicantName (@Model.PaymentGroupings[k].Items[i].InvoiceNumber)</h6></abp-column>
62-
<abp-column size="_1" class="px-1 remove-single-payment"> <abp-button style="float: right" onclick='removeApplicationPayment("@Model.PaymentGroupings[k].Items[i].Id" + "_container", @Model.PaymentGroupings[k].GroupId)' size="Small" icon-type="Other" class="m-0 p-0 remove-single-payment" icon="fa fa-times" data-parameter="@Model.PaymentGroupings[k].Items[i].CorrelationId" /></abp-column>
68+
<input type="hidden" asp-for="@Model.PaymentGroupings[k].Items[i].PreviousL1Approver" />
69+
<input type="hidden" asp-for="@Model.PaymentGroupings[k].Items[i].IsValid" />
70+
<abp-row id=@($"{@Model.PaymentGroupings[k].Items[i].Id}_header") class=" m-0 p-2">
71+
<abp-column size="_12" class="px-1 d-flex align-items-center">
72+
<div class="w-100">
73+
<h6 class="single-payment-card-application-name fw-bold">
74+
@Model.PaymentGroupings[k].Items[i].ApplicantName (@Model.PaymentGroupings[k].Items[i].InvoiceNumber)
75+
</h6>
76+
</div>
77+
<div class="flex-shrink-1">
78+
<abp-button onclick='removeApplicationPayment("@Model.PaymentGroupings[k].Items[i].Id" + "_container", @Model.PaymentGroupings[k].GroupId)'
79+
size="Small" icon-type="Other" class="m-0 p-0 remove-single-payment" icon="fa fa-times"
80+
data-parameter="@Model.PaymentGroupings[k].Items[i].CorrelationId" />
81+
</div>
82+
</abp-column>
6383
</abp-row>
6484
<input type="hidden" asp-for="@Model.PaymentGroupings[k].Items[i].Id" />
6585
<input type="hidden" asp-for="@Model.IsApproval" />
@@ -81,24 +101,34 @@
81101
uniqueIndex++;
82102
}
83103

84-
<abp-row class="m-0 p-3 payment-status-transition">
85-
<abp-column size="_4" class="text-center">
86-
@{
87-
var fromStatusText = UpdatePaymentRequestStatus.GetStatusText(@Model.PaymentGroupings[k].Items[0].Status);
88-
var fromStatusTextColor = UpdatePaymentRequestStatus.GetStatusTextColor(@Model.PaymentGroupings[k].Items[0].Status);
89-
}
90-
<b style="color:@fromStatusTextColor ">@fromStatusText</b>
91-
92-
</abp-column>
93-
<abp-column size="_4" class="text-center">
94-
<span class="fa fa-chevron-right fa-solid fa-bold payment-status-transition-arrow"></span> <span class="fa fa-chevron-right fa-solid fa-bold payment-status-transition-arrow"></span> <span class="fa fa-chevron-right fa-solid fa-bold payment-status-transition-arrow"></span>
95-
</abp-column>
96-
<abp-column size="_4" class="text-center">
97-
@{
98-
var toStatusText = UpdatePaymentRequestStatus.GetStatusText(@Model.PaymentGroupings[k].ToStatus);
99-
var toStatusTextColor = UpdatePaymentRequestStatus.GetStatusTextColor(@Model.PaymentGroupings[k].ToStatus);
100-
}
101-
<b style="color:@toStatusTextColor">@toStatusText</b>
104+
<abp-row class="m-0 payment-status-transition">
105+
<abp-column size="_12" class="d-flex align-items-center p-0 text-center">
106+
<div class="flex-fill">
107+
@{
108+
var fromStatusText = UpdatePaymentRequestStatus.GetStatusText(@Model.PaymentGroupings[k].Items[0].Status);
109+
var fromStatusTextColor = UpdatePaymentRequestStatus.GetStatusTextColor(@Model.PaymentGroupings[k].Items[0].Status);
110+
}
111+
<b style="color:@fromStatusTextColor ">@fromStatusText</b>
112+
</div>
113+
<div class="flex-fill">
114+
@if (Model.PaymentGroupings[k].Items.Any(x => !x.IsValid))
115+
{
116+
<span class="fa fa-ban fa-solid payment-status-transition-ban"></span>
117+
}
118+
else
119+
{
120+
<span class="fa fa-chevron-right fa-solid payment-status-transition-arrow"></span>
121+
<span class="fa fa-chevron-right fa-solid payment-status-transition-arrow"></span>
122+
<span class="fa fa-chevron-right fa-solid payment-status-transition-arrow"></span>
123+
}
124+
</div>
125+
<div class="flex-fill">
126+
@{
127+
var toStatusText = UpdatePaymentRequestStatus.GetStatusText(@Model.PaymentGroupings[k].ToStatus);
128+
var toStatusTextColor = UpdatePaymentRequestStatus.GetStatusTextColor(@Model.PaymentGroupings[k].ToStatus);
129+
}
130+
<b style="color:@toStatusTextColor">@toStatusText</b>
131+
</div>
102132
</abp-column>
103133
</abp-row>
104134
</abp-container>
@@ -110,13 +140,16 @@
110140
<abp-column size="_12" class="px-1"> <p>No Payments Selected</p></abp-column>
111141
</abp-row>
112142
}
113-
<abp-column size="_12" class=" m-0 p-3 payment-error-column">
114-
<span><b>Note: </b>Only payments in @Model.FromStatusText status will appear in this list</span>
143+
<abp-column size="_12" class=" m-0 p-3 payment-error-column text-danger" abp-if="@(!ModelState.IsValid)">
144+
@if(ModelState.ContainsKey(nameof(PaymentsApprovalModel.PreviousL1Approver))
145+
&& ModelState[nameof(PaymentsApprovalModel.PreviousL1Approver)]?.ValidationState == Microsoft.AspNetCore.Mvc.ModelBinding.ModelValidationState.Invalid)
146+
{
147+
<span>@L["ApplicationPaymentRequest:Validations:L2ApproverRestrictionBatch"]</span>
148+
}
115149
</abp-column>
116150
<abp-row class="m-0 p-2 no-payment-msg text-center" id="no-payment-msg" style="display: none;">
117151
<abp-column size="_12" class="px-1"> <p>No Payments Selected</p></abp-column>
118152
</abp-row>
119-
120153
</abp-card-body>
121154
</abp-card>
122155
</abp-modal-body>
@@ -134,6 +167,10 @@
134167
</abp-modal>
135168
</form>
136169

170+
@section Scripts {
171+
<partial name="_ValidationScriptsPartial" />
172+
}
173+
137174
<script defer>
138175
(function () {
139176
if (window.jQuery) {

0 commit comments

Comments
 (0)