diff --git a/src/Apps/W1/ExpenseAgent/app/src/APIs/ApproverViewAPI.Page.al b/src/Apps/W1/ExpenseAgent/app/src/APIs/ApproverViewAPI.Page.al index fad83ab33a2..a0225d4cab8 100644 --- a/src/Apps/W1/ExpenseAgent/app/src/APIs/ApproverViewAPI.Page.al +++ b/src/Apps/W1/ExpenseAgent/app/src/APIs/ApproverViewAPI.Page.al @@ -52,6 +52,14 @@ page 6969 "Approver View API" SubPageLink = "Pending Approval By" = field("No."), Status = filter("Pending Approval" | "Interim Approved"); } + + part(travelRequestsPendingApproval; "Travel Requests API") + { + EntityName = 'travelRequest'; + EntitySetName = 'travelRequests'; + SubPageLink = "Approver User Id Filter" = field(SystemId), + Status = const(Released); + } } } } diff --git a/src/Apps/W1/ExpenseAgent/app/src/APIs/EmployeesAPI.Page.al b/src/Apps/W1/ExpenseAgent/app/src/APIs/EmployeesAPI.Page.al index d416ce41b9b..c09dcd107bd 100644 --- a/src/Apps/W1/ExpenseAgent/app/src/APIs/EmployeesAPI.Page.al +++ b/src/Apps/W1/ExpenseAgent/app/src/APIs/EmployeesAPI.Page.al @@ -25,6 +25,8 @@ page 6917 "Employees API" DataAccessIntent = ReadOnly; SourceTable = Employee; AboutText = 'Lists details about employees that can use the expense functionalities.'; + Permissions = tabledata Traveler = r, + tabledata "Expense User" = r; layout { @@ -41,6 +43,12 @@ page 6917 "Employees API" { Caption = 'Number'; } + field(isExpenseUser; Rec."Is Expense User") + { + Caption = 'Is Expense User'; + Editable = false; + ToolTip = 'Specifies whether the employee is linked to an expense user.'; + } field(name; Rec.FullName()) { Caption = 'Name'; @@ -64,6 +72,11 @@ page 6917 "Employees API" ExpenseAgentAPIValidation.VerifyAgentAccess(); end; + trigger OnOpenPage() + begin + ApplyTravelRequestFilter(); + end; + trigger OnAfterGetRecord() begin CompanyInformation.Get(); @@ -71,6 +84,40 @@ page 6917 "Employees API" OrganizationName := CompanyInformation.Name; end; + local procedure ApplyTravelRequestFilter() + var + EmployeeFilterRecord: Record Employee; + TravelRequestEmployees: Query "Travel Request Employees"; + EmployeeFilter: TextBuilder; + TravelRequestSystemId: Guid; + OriginalFilterGroup: Integer; + begin + OriginalFilterGroup := Rec.FilterGroup(4); + if Rec.GetFilter("Travel Request SystemId Filter") <> '' then + TravelRequestSystemId := Rec.GetRangeMin("Travel Request SystemId Filter"); + Rec.FilterGroup(OriginalFilterGroup); + if IsNullGuid(TravelRequestSystemId) then + exit; + + TravelRequestEmployees.SetRange(travelRequestSystemId, TravelRequestSystemId); + TravelRequestEmployees.Open(); + while TravelRequestEmployees.Read() do + if TravelRequestEmployees.employeeNo <> '' then begin + if EmployeeFilter.Length > 0 then + EmployeeFilter.Append('|'); + EmployeeFilterRecord.SetRange("No.", TravelRequestEmployees.employeeNo); + EmployeeFilter.Append(EmployeeFilterRecord.GetFilter("No.")); + end; + TravelRequestEmployees.Close(); + + OriginalFilterGroup := Rec.FilterGroup(2); + if EmployeeFilter.Length = 0 then + Rec.SetRange(SystemId, CreateGuid()) + else + Rec.SetFilter("No.", EmployeeFilter.ToText()); + Rec.FilterGroup(OriginalFilterGroup); + end; + var CompanyInformation: Record "Company Information"; OrganizationName: Text[100]; diff --git a/src/Apps/W1/ExpenseAgent/app/src/APIs/ExpenseReportsAPI.Page.al b/src/Apps/W1/ExpenseAgent/app/src/APIs/ExpenseReportsAPI.Page.al index 2e266801044..87d3a85d0a1 100644 --- a/src/Apps/W1/ExpenseAgent/app/src/APIs/ExpenseReportsAPI.Page.al +++ b/src/Apps/W1/ExpenseAgent/app/src/APIs/ExpenseReportsAPI.Page.al @@ -235,10 +235,23 @@ page 6928 "Expense Reports API" { Caption = 'Travel Request No.'; } + field(travelRequestId; Rec."Travel Request SystemId") + { + Caption = 'Travel Request Id'; + Editable = false; + } field(spendRequestClose; Rec."Spend Request Close") { Caption = 'Travel Request Close'; } + part(travelRequest; "Travel Requests API") + { + Caption = 'Travel Request'; + EntityName = 'travelRequest'; + EntitySetName = 'travelRequests'; + Multiplicity = ZeroOrOne; + SubPageLink = SystemId = field("Travel Request SystemId"); + } part(expenseReportLines; "Expense Report Lines API") { Caption = 'Expense Report Lines'; diff --git a/src/Apps/W1/ExpenseAgent/app/src/APIs/ExpenseUsersAPI.Page.al b/src/Apps/W1/ExpenseAgent/app/src/APIs/ExpenseUsersAPI.Page.al index 2fc6cdac2ab..7c7f6fbc119 100644 --- a/src/Apps/W1/ExpenseAgent/app/src/APIs/ExpenseUsersAPI.Page.al +++ b/src/Apps/W1/ExpenseAgent/app/src/APIs/ExpenseUsersAPI.Page.al @@ -83,6 +83,13 @@ page 6918 "Expense Users API" SubPageLink = "Expense User No." = field("No."); } + part(travelRequests; "Travel Requests API") + { + EntityName = 'travelRequest'; + EntitySetName = 'travelRequests'; + SubPageLink = "Requested By User Id Filter" = field(SystemId); + } + part(activityHistory; "Expense Activity Log API") { EntityName = 'expenseActivityLogEntry'; diff --git a/src/Apps/W1/ExpenseAgent/app/src/APIs/SpendRequestDetailsAPI.Page.al b/src/Apps/W1/ExpenseAgent/app/src/APIs/SpendRequestDetailsAPI.Page.al index 5d61bdeb808..267abe24b8e 100644 --- a/src/Apps/W1/ExpenseAgent/app/src/APIs/SpendRequestDetailsAPI.Page.al +++ b/src/Apps/W1/ExpenseAgent/app/src/APIs/SpendRequestDetailsAPI.Page.al @@ -21,6 +21,8 @@ page 7102 "Spend Request Details API" SourceTable = "Spend Request Detail"; AboutText = 'Provides access to data from the Spend Request Detail table'; AutoSplitKey = true; + Permissions = tabledata "Spend Request Detail" = rimd, + tabledata "Spend Request" = rm; layout { diff --git a/src/Apps/W1/ExpenseAgent/app/src/APIs/SpendRequestsAPI.Page.al b/src/Apps/W1/ExpenseAgent/app/src/APIs/SpendRequestsAPI.Page.al index 5ac7a3a5cf0..29da492d6e2 100644 --- a/src/Apps/W1/ExpenseAgent/app/src/APIs/SpendRequestsAPI.Page.al +++ b/src/Apps/W1/ExpenseAgent/app/src/APIs/SpendRequestsAPI.Page.al @@ -20,6 +20,9 @@ page 7099 "Spend Requests API" ODataKeyFields = SystemId; SourceTable = "Spend Request"; AboutText = 'Provides access to data from the Spend Request table'; + Permissions = tabledata "Spend Request" = rimd, + tabledata "Spend Request Detail" = rmd, + tabledata "Spend Request To G/L Link" = rd; layout { @@ -39,6 +42,7 @@ page 7099 "Spend Requests API" field(requestedBy; Rec."Requested By") { Caption = 'Requested By'; + ToolTip = 'Specifies the employee who created the request. For travel requests, this value can be set only when creating the request.'; } field(status; Rec.Status) { @@ -166,4 +170,18 @@ page 7099 "Spend Requests API" begin ExpenseAgentAPIValidation.VerifyAgentAccess(); end; + + trigger OnModifyRecord(): Boolean + begin + // Protect travel request ownership through the legacy endpoint. + if (Rec."Document Type" = Rec."Document Type"::"Travel Request") and + (Rec."Requested By" <> xRec."Requested By") + then + Rec.FieldError("Requested By", RequestedByCannotBeChangedErr); + + exit(true); + end; + + var + RequestedByCannotBeChangedErr: Label 'cannot be changed'; } diff --git a/src/Apps/W1/ExpenseAgent/app/src/APIs/TravelRequestDetailsAPI.Page.al b/src/Apps/W1/ExpenseAgent/app/src/APIs/TravelRequestDetailsAPI.Page.al index 3aa7a1d8dca..2389bb26e88 100644 --- a/src/Apps/W1/ExpenseAgent/app/src/APIs/TravelRequestDetailsAPI.Page.al +++ b/src/Apps/W1/ExpenseAgent/app/src/APIs/TravelRequestDetailsAPI.Page.al @@ -21,6 +21,8 @@ page 7135 "Travel Request Details API" SourceTable = "Spend Request Detail"; AboutText = 'Provides access to data from the Travel Request Detail table'; AutoSplitKey = true; + Permissions = tabledata "Spend Request Detail" = rimd, + tabledata "Spend Request" = rm; layout { @@ -36,18 +38,36 @@ page 7135 "Travel Request Details API" field(travelRequestNo; Rec."Spend Request No.") { Caption = 'Travel Request No.'; + ToolTip = 'Specifies the travel request for the detail line.'; + Editable = false; } field(lineNo; Rec."Line No.") { Caption = 'Line No.'; } + field(type; Rec.Type) + { + Caption = 'Type'; + ToolTip = 'Specifies the type of the travel request detail.'; + } + field(expenseCategoryCode; Rec."Expense Category Code") + { + Caption = 'Expense Category Code'; + ToolTip = 'Specifies the expense category for the travel request detail.'; + } field(description; Rec.Description) { Caption = 'Description'; } - field(currencyCode; Rec."Currency Code") + field(currencyCode; CurrencyCodeDisplay) { Caption = 'Currency Code'; + ToolTip = 'Specifies the currency used for estimation. The local currency is represented by its currency code in the API.'; + + trigger OnValidate() + begin + Rec.Validate("Currency Code", CurrencyHelper.GetCurrencyCodeFromAPI(CurrencyCodeDisplay)); + end; } field(expectedAmount; Rec."Expected Amount") { @@ -77,4 +97,23 @@ page 7135 "Travel Request Details API" begin ExpenseAgentAPIValidation.VerifyAgentAccess(); end; + + trigger OnOpenPage() + begin + Rec.AddLoadFields("Currency Code"); + end; + + trigger OnAfterGetRecord() + begin + CurrencyCodeDisplay := CurrencyHelper.GetCurrencyCodeForAPI(Rec."Currency Code"); + end; + + trigger OnNewRecord(BelowxRec: Boolean) + begin + Clear(CurrencyCodeDisplay); + end; + + var + CurrencyHelper: Codeunit "Expense API Currency Helper"; + CurrencyCodeDisplay: Code[10]; } diff --git a/src/Apps/W1/ExpenseAgent/app/src/APIs/TravelRequestsAPI.Page.al b/src/Apps/W1/ExpenseAgent/app/src/APIs/TravelRequestsAPI.Page.al index 952c8dc1ebd..09524c86fb8 100644 --- a/src/Apps/W1/ExpenseAgent/app/src/APIs/TravelRequestsAPI.Page.al +++ b/src/Apps/W1/ExpenseAgent/app/src/APIs/TravelRequestsAPI.Page.al @@ -5,6 +5,7 @@ namespace Microsoft.ExpenseAgent; using Microsoft.Finance.SpendRequest; +using System.Telemetry; page 7134 "Travel Requests API" { @@ -21,6 +22,12 @@ page 7134 "Travel Requests API" SourceTable = "Spend Request"; SourceTableView = where("Document Type" = const("Travel Request")); AboutText = 'Provides access to data from the Travel Request table'; + Permissions = tabledata "Spend Request" = rimd, + tabledata "Spend Request Detail" = rmd, + tabledata "Spend Request To G/L Link" = rd, + tabledata "Expense Report Header" = ri, + tabledata "Posted Expense Report Header" = r, + tabledata "Posted Expense Report Line" = r; layout { @@ -40,6 +47,7 @@ page 7134 "Travel Requests API" field(requestedBy; Rec."Requested By") { Caption = 'Requested By'; + ToolTip = 'Specifies the employee who created the request. This value can be set only when creating the request.'; } field(status; Rec.Status) { @@ -54,9 +62,15 @@ page 7134 "Travel Requests API" { Caption = 'Purpose'; } - field(currencyCode; Rec."Currency Code") + field(currencyCode; CurrencyCodeDisplay) { Caption = 'Currency Code'; + ToolTip = 'Specifies the currency used for estimation. The local currency is represented by its currency code in the API.'; + + trigger OnValidate() + begin + Rec.Validate("Currency Code", CurrencyHelper.GetCurrencyCodeFromAPI(CurrencyCodeDisplay)); + end; } field(totalExpectedAmount; Rec."Total Expected Amount") { @@ -77,13 +91,25 @@ page 7134 "Travel Requests API" Caption = 'Total Line Amount (LCY)'; Editable = false; } - field(expectedStartDate; Rec."Expected Start Date") + field(expectedStartDate; ExpectedStartDate) { Caption = 'Expected Start Date'; + ToolTip = 'Specifies the expected start date of the travel request.'; + + trigger OnValidate() + begin + ExpectedStartDateProvided := true; + end; } - field(expectedEndDate; Rec."Expected End Date") + field(expectedEndDate; ExpectedEndDate) { Caption = 'Expected End Date'; + ToolTip = 'Specifies the expected end date of the travel request.'; + + trigger OnValidate() + begin + ExpectedEndDateProvided := true; + end; } field(closedAt; Rec."Closed At") { @@ -143,6 +169,30 @@ page 7134 "Travel Requests API" { Caption = 'Actual End Date and Time'; } + field(submittedByExpenseUserNo; Rec."Submitted By Expense User No.") + { + Caption = 'Submitted By Expense User No.'; + ToolTip = 'Specifies the expense user who submitted the travel request.'; + Editable = false; + } + field(submittedAt; Rec."Submitted At") + { + Caption = 'Submitted At'; + ToolTip = 'Specifies the date and time when the travel request was submitted.'; + Editable = false; + } + field(approvalExpenseUserNo; Rec."Approval Expense User No.") + { + Caption = 'Approval Expense User No.'; + ToolTip = 'Specifies the expense user who approved or rejected the travel request.'; + Editable = false; + } + field(rejectionReason; Rec."Rejection Reason") + { + Caption = 'Rejection Reason'; + ToolTip = 'Specifies the reason the travel request was rejected.'; + Editable = false; + } part(travelRequestDetails; "Travel Request Details API") { Caption = 'Travel Request Details'; @@ -157,6 +207,13 @@ page 7134 "Travel Requests API" EntitySetName = 'travelers'; SubPageLink = "Spend Request No." = field("No."); } + part(employees; "Employees API") + { + Caption = 'Employees'; + EntityName = 'employee'; + EntitySetName = 'employees'; + SubPageLink = "Travel Request SystemId Filter" = field(SystemId); + } } } } @@ -167,4 +224,215 @@ page 7134 "Travel Requests API" begin ExpenseAgentAPIValidation.VerifyAgentAccess(); end; + + trigger OnOpenPage() + begin + Rec.AddLoadFields("Currency Code", "Expected Start Date", "Expected End Date"); + end; + + trigger OnAfterGetRecord() + begin + CurrencyCodeDisplay := CurrencyHelper.GetCurrencyCodeForAPI(Rec."Currency Code"); + ExpectedStartDate := Rec."Expected Start Date"; + ExpectedEndDate := Rec."Expected End Date"; + ExpectedStartDateProvided := false; + ExpectedEndDateProvided := false; + end; + + trigger OnNewRecord(BelowxRec: Boolean) + var + OwnerEmployeeNo: Code[20]; + begin + OwnerEmployeeNo := ProcessOwnerFilter(); + if OwnerEmployeeNo <> '' then + Rec."Requested By" := OwnerEmployeeNo; + Clear(CurrencyCodeDisplay); + Clear(ExpectedStartDate); + Clear(ExpectedEndDate); + ExpectedStartDateProvided := false; + ExpectedEndDateProvided := false; + end; + + [ServiceEnabled] + procedure SubmitTravelRequest(var ActionContext: WebServiceActionContext; SubmitterExpenseUserNo: Code[20]) + var + TravelRequestApproval: Codeunit "Travel Request Approval"; + begin + TravelRequestApproval.Submit(Rec, SubmitterExpenseUserNo); + SetActionResponse(ActionContext); + end; + + [ServiceEnabled] + procedure ApproveTravelRequest(var ActionContext: WebServiceActionContext; ApproverExpenseUserNo: Code[20]) + var + TravelRequestApproval: Codeunit "Travel Request Approval"; + begin + TravelRequestApproval.Approve(Rec, ApproverExpenseUserNo); + SetActionResponse(ActionContext); + end; + + [ServiceEnabled] + procedure RejectTravelRequest(var ActionContext: WebServiceActionContext; ApproverExpenseUserNo: Code[20]; RejectReason: Text) + var + TravelRequestApproval: Codeunit "Travel Request Approval"; + begin + TravelRequestApproval.Reject(Rec, ApproverExpenseUserNo, RejectReason); + SetActionResponse(ActionContext); + end; + + [ServiceEnabled] + procedure CreateExpenseReport(var ActionContext: WebServiceActionContext) + var + ExpenseReportHeader: Record "Expense Report Header"; + begin + CheckOwnerScopeRequired(); + Rec.TestField("Document Type", Rec."Document Type"::"Travel Request"); + if Rec.Status <> Rec.Status::Approved then + Error(TravelRequestMustBeApprovedErr, Rec."No."); + Rec.TestField("Requested For"); + + if not ExpenseReportHeader.CreateFromApprovedTravelRequestIfMissing(Rec) then begin + ExpenseReportHeader.SetRange("Spend Request No.", Rec."No."); + ExpenseReportHeader.SetRange("Expense User No.", Rec."Requested For"); + ExpenseReportHeader.SetLoadFields("No."); + ExpenseReportHeader.FindFirst(); + Error(GetExpenseReportAlreadyLinkedError(ExpenseReportHeader, Rec)); + end; + + LogCreateExpenseReport(); + ActionContext.SetObjectType(ObjectType::Page); + ActionContext.SetObjectId(Page::"Expense Reports API"); + ActionContext.AddEntityKey(ExpenseReportHeader.FieldNo(SystemId), ExpenseReportHeader.SystemId); + ActionContext.SetResultCode(WebServiceActionResultCode::Created); + end; + + local procedure GetExpenseReportAlreadyLinkedError(ExpenseReportHeader: Record "Expense Report Header"; TravelRequest: Record "Spend Request"): ErrorInfo + var + ExpenseReportAlreadyLinkedError: ErrorInfo; + begin + ExpenseReportAlreadyLinkedError.Message := StrSubstNo( + ExpenseReportAlreadyLinkedErr, TravelRequest."Requested For", ExpenseReportHeader."No.", TravelRequest."No."); + ExpenseReportAlreadyLinkedError.Title := ExpenseReportAlreadyLinkedTitleErr; + ExpenseReportAlreadyLinkedError.DetailedMessage := ExpenseReportAlreadyLinkedDetailsErr; + ExpenseReportAlreadyLinkedError.DataClassification := DataClassification::EndUserIdentifiableInformation; + ExpenseReportAlreadyLinkedError.ErrorType := ErrorType::Client; + ExpenseReportAlreadyLinkedError.RecordId := ExpenseReportHeader.RecordId; + ExpenseReportAlreadyLinkedError.PageNo := Page::"Expense Report"; + ExpenseReportAlreadyLinkedError.AddNavigationAction(ShowItLbl); + exit(ExpenseReportAlreadyLinkedError); + end; + + local procedure LogCreateExpenseReport() + var + ExpenseAgentSetup: Record "Expense Agent Setup"; + FeatureTelemetry: Codeunit "Feature Telemetry"; + begin + FeatureTelemetry.LogUsage('0000VF2', ExpenseAgentSetup.GetFeatureName(), ExpenseReportCreatedLbl); + end; + + trigger OnFindRecord(Which: Text): Boolean + begin + ProcessOwnerFilter(); + ProcessApproverFilter(); + exit(Rec.Find(Which)); + end; + + trigger OnInsertRecord(BelowxRec: Boolean): Boolean + begin + Rec."Document Type" := Rec."Document Type"::"Travel Request"; + Rec.TestField("Requested By"); + CheckOwnerScope(); + Rec.SetExpectedDatesForAPIInsert(ExpectedStartDate, ExpectedEndDate, ExpectedStartDateProvided, ExpectedEndDateProvided); + exit(true); + end; + + trigger OnModifyRecord(): Boolean + begin + if Rec.Status <> xRec.Status then + Rec.FieldError(Status, StatusCannotBeChangedErr); + // Allow the owner on POST and unchanged in PATCH payloads, but reject reassignment. + if Rec."Requested By" <> xRec."Requested By" then + Rec.FieldError("Requested By", RequestedByCannotBeChangedErr); + + CheckOwnerScope(); + Rec.ApplyExpectedDatesFromAPI(ExpectedStartDate, ExpectedEndDate, ExpectedStartDateProvided, ExpectedEndDateProvided); + exit(true); + end; + + local procedure ProcessOwnerFilter() OwnerEmployeeNo: Code[20] + var + TravelRequestApproval: Codeunit "Travel Request Approval"; + OwnerSystemId: Guid; + OriginalFilterGroup: Integer; + begin + OriginalFilterGroup := Rec.FilterGroup(4); + if Rec.GetFilter("Requested By User Id Filter") <> '' then begin + OwnerSystemId := Rec.GetRangeMin("Requested By User Id Filter"); + OwnerEmployeeNo := TravelRequestApproval.ApplyOwnerFilter(Rec, OwnerSystemId); + end; + Rec.FilterGroup(OriginalFilterGroup); + end; + + local procedure CheckOwnerScope() + var + OwnerEmployeeNo: Code[20]; + begin + OwnerEmployeeNo := ProcessOwnerFilter(); + if OwnerEmployeeNo <> '' then + Rec.TestField("Requested By", OwnerEmployeeNo); + end; + + local procedure CheckOwnerScopeRequired() + var + OwnerEmployeeNo: Code[20]; + begin + OwnerEmployeeNo := ProcessOwnerFilter(); + if OwnerEmployeeNo = '' then + Error(OwnerScopeRequiredErr); + Rec.TestField("Requested By", OwnerEmployeeNo); + end; + + local procedure ProcessApproverFilter() + var + TravelRequestApproval: Codeunit "Travel Request Approval"; + ApproverExpenseUserNo: Code[20]; + ApproverSystemId: Guid; + OriginalFilterGroup: Integer; + begin + OriginalFilterGroup := Rec.FilterGroup(4); + if Rec.GetFilter("Approver User Id Filter") <> '' then begin + ApproverSystemId := Rec.GetRangeMin("Approver User Id Filter"); + TravelRequestApproval.ApplyApproverFilter(Rec, ApproverSystemId); + end else begin + ApproverExpenseUserNo := CopyStr(Rec.GetFilter("Approver Expense User Filter"), 1, MaxStrLen(ApproverExpenseUserNo)); + if ApproverExpenseUserNo <> '' then + TravelRequestApproval.ApplyApproverFilter(Rec, ApproverExpenseUserNo); + end; + Rec.FilterGroup(OriginalFilterGroup); + end; + + local procedure SetActionResponse(var ActionContext: WebServiceActionContext) + begin + ActionContext.SetObjectType(ObjectType::Page); + ActionContext.SetObjectId(Page::"Travel Requests API"); + ActionContext.AddEntityKey(Rec.FieldNo(SystemId), Rec.SystemId); + ActionContext.SetResultCode(WebServiceActionResultCode::Updated); + end; + + var + CurrencyHelper: Codeunit "Expense API Currency Helper"; + CurrencyCodeDisplay: Code[10]; + ExpectedStartDate: Date; + ExpectedEndDate: Date; + ExpectedStartDateProvided: Boolean; + ExpectedEndDateProvided: Boolean; + StatusCannotBeChangedErr: Label 'can be changed only by submitting, approving, or rejecting the travel request'; + RequestedByCannotBeChangedErr: Label 'cannot be changed'; + TravelRequestMustBeApprovedErr: Label 'Travel request %1 must be approved before an expense report can be created.', Comment = '%1 = Travel Request No.'; + ExpenseReportAlreadyLinkedErr: Label 'Expense user %1 already has expense report %2 linked to travel request %3.', Comment = '%1 = Expense User No., %2 = Expense Report No., %3 = Travel Request No.'; + ExpenseReportAlreadyLinkedTitleErr: Label 'Expense report already exists'; + ExpenseReportAlreadyLinkedDetailsErr: Label 'Open the existing expense report linked to this travel request.'; + ExpenseReportCreatedLbl: Label 'Expense report created from approved travel request', Locked = true; + ShowItLbl: Label 'Show it'; + OwnerScopeRequiredErr: Label 'The create expense report action must be invoked through the owning expense user.'; } diff --git a/src/Apps/W1/ExpenseAgent/app/src/APIs/TravelersAPI.Page.al b/src/Apps/W1/ExpenseAgent/app/src/APIs/TravelersAPI.Page.al index b4e5f528bb0..34d5cdcf92d 100644 --- a/src/Apps/W1/ExpenseAgent/app/src/APIs/TravelersAPI.Page.al +++ b/src/Apps/W1/ExpenseAgent/app/src/APIs/TravelersAPI.Page.al @@ -4,6 +4,8 @@ // ------------------------------------------------------------------------------------------------ namespace Microsoft.ExpenseAgent; +using Microsoft.Finance.SpendRequest; + page 7103 "Travelers API" { APIGroup = 'expense'; @@ -19,6 +21,8 @@ page 7103 "Travelers API" SourceTable = Traveler; AboutText = 'Provides access to data from the Traveler table'; AutoSplitKey = true; + Permissions = tabledata "Spend Request" = r, + tabledata Traveler = rimd; layout { @@ -34,19 +38,38 @@ page 7103 "Travelers API" field(spendRequestNo; Rec."Spend Request No.") { Caption = 'Travel Request No.'; + Editable = false; } field(lineNo; Rec."Line No.") { Caption = 'Line No.'; } + field(employeeNumber; EmployeeNumber) + { + Caption = 'Employee Number'; + ToolTip = 'Specifies the employee number of the traveler.'; + + trigger OnValidate() + begin + Rec.ValidateEmployeeNo(EmployeeNumber); + end; + } +#if not CLEAN29 field(expenseUserNo; Rec."Expense User No.") { Caption = 'Expense User No.'; + ObsoleteReason = 'Use employeeNumber instead. Expense User identifiers are an internal implementation detail.'; + ObsoleteState = Pending; + ObsoleteTag = '29.0'; } field(expenseUserName; Rec."Expense User Name") { Caption = 'Expense User Name'; + ObsoleteReason = 'Use employeeNumber and the employees navigation instead.'; + ObsoleteState = Pending; + ObsoleteTag = '29.0'; } +#endif } } } @@ -57,4 +80,14 @@ page 7103 "Travelers API" begin ExpenseAgentAPIValidation.VerifyAgentAccess(); end; + + trigger OnAfterGetRecord() + begin + // The variable-backed API control does not automatically calculate its source FlowField. + Rec.CalcFields("Employee No."); + EmployeeNumber := Rec."Employee No."; + end; + + var + EmployeeNumber: Code[20]; } diff --git a/src/Apps/W1/ExpenseAgent/app/src/Capabilities/ExpenseCapabilitiesProvider.Codeunit.al b/src/Apps/W1/ExpenseAgent/app/src/Capabilities/ExpenseCapabilitiesProvider.Codeunit.al index ad5c5f68791..5a4e2a14012 100644 --- a/src/Apps/W1/ExpenseAgent/app/src/Capabilities/ExpenseCapabilitiesProvider.Codeunit.al +++ b/src/Apps/W1/ExpenseAgent/app/src/Capabilities/ExpenseCapabilitiesProvider.Codeunit.al @@ -32,13 +32,12 @@ codeunit 6906 "Expense Capabilities Provider" exit(IsConsolidatedProjectsEnabled()); Capability::VATSpecifications: exit(IsVATSpecificationsEnabled()); - Capability::ActivityLog: - exit(true); Capability::AiAssistedPolicyEvaluation: exit(IsAiAssistedPolicyEvaluationEnabled()); - Capability::MileageRateSetup: - exit(true); - Capability::ApprovalConversation: + Capability::ActivityLog, + Capability::MileageRateSetup, + Capability::ApprovalConversation, + Capability::TravelRequest: exit(true); end; exit(false); diff --git a/src/Apps/W1/ExpenseAgent/app/src/Capabilities/ExpenseCapability.Enum.al b/src/Apps/W1/ExpenseAgent/app/src/Capabilities/ExpenseCapability.Enum.al index 235147dcf1b..14b8582d27f 100644 --- a/src/Apps/W1/ExpenseAgent/app/src/Capabilities/ExpenseCapability.Enum.al +++ b/src/Apps/W1/ExpenseAgent/app/src/Capabilities/ExpenseCapability.Enum.al @@ -89,4 +89,12 @@ enum 6984 "Expense Capability" { Caption = 'Approval Conversation', Locked = true; } + + /// + /// Travel requests are available in the expense app. + /// + value(8; TravelRequest) + { + Caption = 'Travel Request', Locked = true; + } } diff --git a/src/Apps/W1/ExpenseAgent/app/src/Common/Extensions/ExpenseEmployee.TableExt.al b/src/Apps/W1/ExpenseAgent/app/src/Common/Extensions/ExpenseEmployee.TableExt.al new file mode 100644 index 00000000000..b7d728a626d --- /dev/null +++ b/src/Apps/W1/ExpenseAgent/app/src/Common/Extensions/ExpenseEmployee.TableExt.al @@ -0,0 +1,27 @@ +// ------------------------------------------------------------------------------------------------ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. See License.txt in the project root for license information. +// ------------------------------------------------------------------------------------------------ +namespace Microsoft.ExpenseAgent; + +using Microsoft.HumanResources.Employee; + +tableextension 7110 "Expense Employee" extends Employee +{ + fields + { + field(7100; "Is Expense User"; Boolean) + { + Access = Internal; + Caption = 'Is Expense User'; + FieldClass = FlowField; + CalcFormula = exist("Expense User" where("Employee No." = field("No."))); + } + field(7101; "Travel Request SystemId Filter"; Guid) + { + Access = Internal; + Caption = 'Travel Request SystemId Filter'; + FieldClass = FlowFilter; + } + } +} diff --git a/src/Apps/W1/ExpenseAgent/app/src/Common/Extensions/ExpenseSpendRequest.TableExt.al b/src/Apps/W1/ExpenseAgent/app/src/Common/Extensions/ExpenseSpendRequest.TableExt.al index 3fc56366082..9f7731183fa 100644 --- a/src/Apps/W1/ExpenseAgent/app/src/Common/Extensions/ExpenseSpendRequest.TableExt.al +++ b/src/Apps/W1/ExpenseAgent/app/src/Common/Extensions/ExpenseSpendRequest.TableExt.al @@ -17,13 +17,14 @@ tableextension 6908 "Expense Spend Request" extends "Spend Request" { Caption = 'Requested For'; ToolTip = 'Specifies the expense user for whom the spend request is being created.'; - DataClassification = CustomerContent; + DataClassification = EndUserIdentifiableInformation; TableRelation = "Expense User"; trigger OnValidate() begin TestStatusOpen(); - UpdateRequestedForTraveler(xRec."Requested For"); + if SpendRequestExists() then + UpdateRequestedForTraveler(xRec."Requested For"); end; } field(6901; "Business Justification"; Text[2048]) @@ -129,7 +130,98 @@ tableextension 6908 "Expense Spend Request" extends "Spend Request" TestStatusOpen(); end; } + field(6913; "Approver Expense User Filter"; Code[20]) + { + Caption = 'Approver Expense User Filter'; + FieldClass = FlowFilter; + TableRelation = "Expense User"."No."; + } + field(6914; "Submitted By Expense User No."; Code[20]) + { + Caption = 'Submitted By Expense User No.'; + DataClassification = EndUserIdentifiableInformation; + Editable = false; + TableRelation = "Expense User"."No."; + } + field(6915; "Submitted At"; DateTime) + { + Caption = 'Submitted At'; + DataClassification = SystemMetadata; + Editable = false; + } + field(6916; "Approval Expense User No."; Code[20]) + { + Caption = 'Approval Expense User No.'; + DataClassification = EndUserIdentifiableInformation; + Editable = false; + TableRelation = "Expense User"."No."; + } + field(6917; "Rejection Reason"; Text[2048]) + { + Caption = 'Rejection Reason'; + DataClassification = CustomerContent; + Editable = false; + } + field(6918; "Requested By User Id Filter"; Guid) + { + Caption = 'Requested By User Id Filter'; + FieldClass = FlowFilter; + TableRelation = "Expense User".SystemId; + } + field(6919; "Approver User Id Filter"; Guid) + { + Caption = 'Approver User Id Filter'; + FieldClass = FlowFilter; + TableRelation = "Expense User".SystemId; + } } + trigger OnInsert() + var + StartDateProvided: Boolean; + EndDateProvided: Boolean; + begin + StartDateProvided := APIStartDateProvided; + EndDateProvided := APIEndDateProvided; + APIStartDateProvided := false; + APIEndDateProvided := false; + + // The base OnInsert initializes both dates to WorkDate. Restore API inputs before persistence. + ApplyExpectedDatesFromAPI(APIExpectedStartDate, APIExpectedEndDate, StartDateProvided, EndDateProvided); + end; + + trigger OnAfterInsert() + begin + if Rec."Document Type" = Rec."Document Type"::"Travel Request" then + InsertRequestedForTraveler(); + end; + + trigger OnBeforeDelete() + var + ExpenseReportHeader: Record "Expense Report Header"; + ExpenseReportLine: Record "Expense Report Line"; + PostedExpenseReportHeader: Record "Posted Expense Report Header"; + PostedExpenseReportLine: Record "Posted Expense Report Line"; + begin + if Rec."Document Type" <> Rec."Document Type"::"Travel Request" then + exit; + + ExpenseReportHeader.SetRange("Spend Request No.", Rec."No."); + if ExpenseReportHeader.FindFirst() then + Error(GetLinkedExpenseReportError(ExpenseReportHeader.RecordId, Page::"Expense Report")); + + ExpenseReportLine.SetRange("Spend Request No.", Rec."No."); + if ExpenseReportLine.FindFirst() then + Error(GetLinkedExpenseReportError(ExpenseReportLine.RecordId, Page::"Expense Report Lines")); + + PostedExpenseReportHeader.SetRange("Spend Request No.", Rec."No."); + if PostedExpenseReportHeader.FindFirst() then + Error(GetLinkedExpenseReportError(PostedExpenseReportHeader.RecordId, Page::"Posted Expense Report")); + + PostedExpenseReportLine.SetRange("Spend Request No.", Rec."No."); + if PostedExpenseReportLine.FindFirst() then + Error(GetLinkedExpenseReportError(PostedExpenseReportLine.RecordId, Page::"Posted Expense Report Lines")); + end; + trigger OnDelete() var Traveler: Record Traveler; @@ -139,8 +231,53 @@ tableextension 6908 "Expense Spend Request" extends "Spend Request" end; var + APIExpectedStartDate: Date; + APIExpectedEndDate: Date; + APIStartDateProvided: Boolean; + APIEndDateProvided: Boolean; ReplaceRequestedForTravelerQst: Label 'The %1 was changed. A traveler was automatically added for the previous %1. Do you want to remove that traveler and add a new one for the current %1 instead?', Comment = '%1 = Requested For field caption'; + local procedure GetLinkedExpenseReportError(ReportRecordId: RecordId; ReportPageNo: Integer): ErrorInfo + var + LinkedReportError: ErrorInfo; + LinkedExpenseReportExistsErr: Label 'You cannot delete travel request %1 because it is linked to an expense report.', Comment = '%1 = Travel request number'; + LinkedExpenseReportTitleErr: Label 'Travel request is linked to an expense report'; + LinkedExpenseReportDetailsErr: Label 'Open the related report to see where this travel request is used. Posted history cannot be removed by deleting the travel request.'; + ShowItLbl: Label 'Show it'; + begin + LinkedReportError.Message := StrSubstNo(LinkedExpenseReportExistsErr, Rec."No."); + LinkedReportError.Title := LinkedExpenseReportTitleErr; + LinkedReportError.DetailedMessage := LinkedExpenseReportDetailsErr; + LinkedReportError.DataClassification := DataClassification::CustomerContent; + LinkedReportError.ErrorType := ErrorType::Client; + LinkedReportError.RecordId := ReportRecordId; + LinkedReportError.PageNo := ReportPageNo; + LinkedReportError.AddNavigationAction(ShowItLbl); + exit(LinkedReportError); + end; + + internal procedure SetExpectedDatesForAPIInsert(StartDate: Date; EndDate: Date; StartDateProvided: Boolean; EndDateProvided: Boolean) + begin + APIExpectedStartDate := StartDate; + APIExpectedEndDate := EndDate; + APIStartDateProvided := StartDateProvided; + APIEndDateProvided := EndDateProvided; + end; + + internal procedure ApplyExpectedDatesFromAPI(StartDate: Date; EndDate: Date; StartDateProvided: Boolean; EndDateProvided: Boolean) + begin + // Populate the final pair before either field trigger validates it; omitted values remain unchanged. + if StartDateProvided then + Rec."Expected Start Date" := StartDate; + if EndDateProvided then + Rec."Expected End Date" := EndDate; + + if StartDateProvided then + Rec.Validate("Expected Start Date"); + if EndDateProvided then + Rec.Validate("Expected End Date"); + end; + internal procedure InsertRequestedForTraveler() var Traveler: Record Traveler; @@ -212,4 +349,15 @@ tableextension 6908 "Expense Spend Request" extends "Spend Request" Rec.Validate("International Travel", Rec."Origin Country/Region Code" <> Rec."Dest. Country/Region Code"); end; + + local procedure SpendRequestExists(): Boolean + var + SpendRequest: Record "Spend Request"; + begin + if Rec."No." = '' then + exit(false); + + SpendRequest.SetLoadFields("No."); + exit(SpendRequest.Get(Rec."No.")); + end; } \ No newline at end of file diff --git a/src/Apps/W1/ExpenseAgent/app/src/ExpenseReport/Tables/ExpenseReportHeader.Table.al b/src/Apps/W1/ExpenseAgent/app/src/ExpenseReport/Tables/ExpenseReportHeader.Table.al index 71414bb2a25..29c124a6730 100644 --- a/src/Apps/W1/ExpenseAgent/app/src/ExpenseReport/Tables/ExpenseReportHeader.Table.al +++ b/src/Apps/W1/ExpenseAgent/app/src/ExpenseReport/Tables/ExpenseReportHeader.Table.al @@ -558,6 +558,7 @@ table 6906 "Expense Report Header" begin if Rec."Spend Request No." <> '' then begin CheckTraveler(); + SpendRequest.SetSkipSpendRequestClose(GetHideValidationDialog()); SpendRequest.ValidateSpendRequest(Rec."Spend Request No.", Rec."Spend Request Close"); if SpendRequest."Dimension Set ID" <> 0 then begin @@ -578,6 +579,14 @@ table 6906 "Expense Report Header" ToolTip = 'Specifies that the travel request will be closed when the expense report is posted.'; DataClassification = CustomerContent; } + field(102; "Travel Request SystemId"; Guid) + { + Caption = 'Travel Request SystemId'; + ToolTip = 'Specifies the immutable SystemId of the travel request that is associated with this expense report.'; + Editable = false; + FieldClass = FlowField; + CalcFormula = lookup("Spend Request".SystemId where("No." = field("Spend Request No."))); + } } keys @@ -586,6 +595,9 @@ table 6906 "Expense Report Header" { Clustered = true; } + key(SpendRequestNo; "Spend Request No.", "Expense User No.") + { + } } fieldgroups @@ -646,6 +658,7 @@ table 6906 "Expense Report Header" ExpenseAgentAPIValidation: Codeunit "Expense Agent API Validation"; CurrencyDate: Date; HideValidationDialog: Boolean; + SkipExpenseUserApprovalCheck: Boolean; CalledFromExpenseAgent: Boolean; EmptyGuid: Guid; DimChangeQst: Label 'You may have changed a dimension.\\Do you want to update the lines?'; @@ -1312,6 +1325,9 @@ table 6906 "Expense Report Header" ExpenseUser: Record "Expense User"; ExpenseReportApprovalMgmt: Codeunit "Expense Report Approval Mgmt"; begin + if SkipExpenseUserApprovalCheck then + exit; + ExpenseAgentSetup.GetRecordOnce(); if not ExpenseAgentSetup."Enable Approval Workflow" then exit; @@ -1331,6 +1347,113 @@ table 6906 "Expense Report Header" CalledFromExpenseAgent := NewCalledFromExpenseAgent; end; + [CommitBehavior(CommitBehavior::Ignore)] + internal procedure CreateFromApprovedTravelRequest(SpendRequest: Record "Spend Request") + begin + CreateFromApprovedTravelRequestIfMissing(SpendRequest); + end; + + [CommitBehavior(CommitBehavior::Ignore)] + internal procedure CreateFromApprovedTravelRequestIfMissing(SpendRequest: Record "Spend Request"): Boolean + begin + // Serialize creation for this request even when no expense report exists yet. + SpendRequest.LockTable(); + SpendRequest.Get(SpendRequest."No."); + SpendRequest.TestField("Document Type", SpendRequest."Document Type"::"Travel Request"); + SpendRequest.TestStatus(SpendRequest.Status::Approved); + SpendRequest.TestField("Requested For"); + + Rec.Reset(); + Rec.LockTable(); + Rec.SetRange("Spend Request No.", SpendRequest."No."); + Rec.SetRange("Expense User No.", SpendRequest."Requested For"); + if not Rec.IsEmpty() then + exit(false); + + CheckPostedTravelRequestReports(SpendRequest); + + Rec.Reset(); + Rec.Init(); + Rec.Validate(Description, CopyStr(SpendRequest.Purpose, 1, MaxStrLen(Rec.Description))); + Rec.ValidateExpenseUserFromApprovedTravelRequest(SpendRequest."Requested For"); + Rec.Validate("Reimbursement Currency Code", SpendRequest."Currency Code"); + Rec.SetHideValidationDialog(true); + Rec.Validate("Spend Request No.", SpendRequest."No."); + OnBeforeCreateFromApprovedTravelRequest(SpendRequest, Rec); + Rec.Insert(true); + OnAfterCreateFromApprovedTravelRequest(SpendRequest, Rec); + exit(true); + end; + + internal procedure HasPostedTravelRequestReport(SpendRequest: Record "Spend Request"): Boolean + var + PostedReportError: ErrorInfo; + begin + exit(TryGetPostedTravelRequestReportError(SpendRequest, PostedReportError)); + end; + + local procedure CheckPostedTravelRequestReports(SpendRequest: Record "Spend Request") + var + PostedReportError: ErrorInfo; + begin + if TryGetPostedTravelRequestReportError(SpendRequest, PostedReportError) then + Error(PostedReportError); + end; + + local procedure TryGetPostedTravelRequestReportError(SpendRequest: Record "Spend Request"; var PostedReportError: ErrorInfo): Boolean + var + PostedExpenseReportHeader: Record "Posted Expense Report Header"; + PostedExpenseReportLine: Record "Posted Expense Report Line"; + begin + PostedExpenseReportHeader.ReadIsolation := IsolationLevel::ReadCommitted; + PostedExpenseReportHeader.SetRange("Spend Request No.", SpendRequest."No."); + PostedExpenseReportHeader.SetRange("Expense User No.", SpendRequest."Requested For"); + PostedExpenseReportHeader.SetLoadFields("No."); + if PostedExpenseReportHeader.FindFirst() then begin + PostedReportError := GetPostedTravelRequestReportError( + SpendRequest, PostedExpenseReportHeader."No.", PostedExpenseReportHeader.RecordId, Page::"Posted Expense Report"); + exit(true); + end; + + PostedExpenseReportLine.ReadIsolation := IsolationLevel::ReadCommitted; + PostedExpenseReportLine.SetRange("Spend Request No.", SpendRequest."No."); + PostedExpenseReportLine.SetRange("Expense User No.", SpendRequest."Requested For"); + PostedExpenseReportLine.SetLoadFields("Document No.", "Line No."); + if PostedExpenseReportLine.FindFirst() then begin + PostedReportError := GetPostedTravelRequestReportError( + SpendRequest, PostedExpenseReportLine."Document No.", PostedExpenseReportLine.RecordId, Page::"Posted Expense Report Lines"); + exit(true); + end; + + exit(false); + end; + + local procedure GetPostedTravelRequestReportError(SpendRequest: Record "Spend Request"; ReportNo: Code[20]; ReportRecordId: RecordId; ReportPageNo: Integer): ErrorInfo + var + PostedReportError: ErrorInfo; + PostedReportExistsErr: Label 'Expense user %1 already has posted expense report %2 linked to travel request %3.', Comment = '%1 = Expense User No., %2 = Posted Expense Report No., %3 = Travel Request No.'; + PostedReportTitleErr: Label 'Expense report has already been posted'; + PostedReportDetailsErr: Label 'Open the posted expense report to review the existing travel request expenses. A new report cannot be created for the same travel request and expense user after posting.'; + ShowItLbl: Label 'Show it'; + begin + PostedReportError.Message := StrSubstNo(PostedReportExistsErr, SpendRequest."Requested For", ReportNo, SpendRequest."No."); + PostedReportError.Title := PostedReportTitleErr; + PostedReportError.DetailedMessage := PostedReportDetailsErr; + PostedReportError.DataClassification := DataClassification::EndUserIdentifiableInformation; + PostedReportError.ErrorType := ErrorType::Client; + PostedReportError.RecordId := ReportRecordId; + PostedReportError.PageNo := ReportPageNo; + PostedReportError.AddNavigationAction(ShowItLbl); + exit(PostedReportError); + end; + + internal procedure ValidateExpenseUserFromApprovedTravelRequest(ExpenseUserNo: Code[20]) + begin + SkipExpenseUserApprovalCheck := true; + Rec.Validate("Expense User No.", ExpenseUserNo); + SkipExpenseUserApprovalCheck := false; + end; + local procedure CheckTraveler() var Traveler: Record Traveler; @@ -1347,4 +1470,14 @@ table 6906 "Expense Report Header" local procedure OnCheckExpenseReportPostRestrictions() begin end; + + [IntegrationEvent(false, false)] + local procedure OnBeforeCreateFromApprovedTravelRequest(SpendRequest: Record "Spend Request"; var ExpenseReportHeader: Record "Expense Report Header") + begin + end; + + [IntegrationEvent(false, false)] + local procedure OnAfterCreateFromApprovedTravelRequest(SpendRequest: Record "Spend Request"; var ExpenseReportHeader: Record "Expense Report Header") + begin + end; } diff --git a/src/Apps/W1/ExpenseAgent/app/src/ExpenseReport/Tables/ExpenseReportLine.Table.al b/src/Apps/W1/ExpenseAgent/app/src/ExpenseReport/Tables/ExpenseReportLine.Table.al index 1c6ddba39db..e8bb7739c52 100644 --- a/src/Apps/W1/ExpenseAgent/app/src/ExpenseReport/Tables/ExpenseReportLine.Table.al +++ b/src/Apps/W1/ExpenseAgent/app/src/ExpenseReport/Tables/ExpenseReportLine.Table.al @@ -1115,6 +1115,9 @@ table 6907 "Expense Report Line" { Clustered = true; } + key(SpendRequestNo; "Spend Request No.") + { + } } trigger OnInsert() diff --git a/src/Apps/W1/ExpenseAgent/app/src/ExpenseReport/Tables/PostedExpenseReportHeader.Table.al b/src/Apps/W1/ExpenseAgent/app/src/ExpenseReport/Tables/PostedExpenseReportHeader.Table.al index 19fe5aaeb00..25dac0539f9 100644 --- a/src/Apps/W1/ExpenseAgent/app/src/ExpenseReport/Tables/PostedExpenseReportHeader.Table.al +++ b/src/Apps/W1/ExpenseAgent/app/src/ExpenseReport/Tables/PostedExpenseReportHeader.Table.al @@ -304,6 +304,12 @@ table 6915 "Posted Expense Report Header" { Clustered = true; } + key(SpendRequestNo; "Spend Request No.") + { + } + key(SpendRequestExpenseUser; "Spend Request No.", "Expense User No.") + { + } key(ExpenseUser; "Expense User No.", "No.") { } diff --git a/src/Apps/W1/ExpenseAgent/app/src/ExpenseReport/Tables/PostedExpenseReportLine.Table.al b/src/Apps/W1/ExpenseAgent/app/src/ExpenseReport/Tables/PostedExpenseReportLine.Table.al index 6d1d22f48fb..d98d75dcac8 100644 --- a/src/Apps/W1/ExpenseAgent/app/src/ExpenseReport/Tables/PostedExpenseReportLine.Table.al +++ b/src/Apps/W1/ExpenseAgent/app/src/ExpenseReport/Tables/PostedExpenseReportLine.Table.al @@ -482,6 +482,12 @@ table 6916 "Posted Expense Report Line" { Clustered = true; } + key(SpendRequestNo; "Spend Request No.") + { + } + key(SpendRequestExpenseUser; "Spend Request No.", "Expense User No.") + { + } } var diff --git a/src/Apps/W1/ExpenseAgent/app/src/MasterData/Tables/ExpenseUser.Table.al b/src/Apps/W1/ExpenseAgent/app/src/MasterData/Tables/ExpenseUser.Table.al index 6f8547043a7..e914142738f 100644 --- a/src/Apps/W1/ExpenseAgent/app/src/MasterData/Tables/ExpenseUser.Table.al +++ b/src/Apps/W1/ExpenseAgent/app/src/MasterData/Tables/ExpenseUser.Table.al @@ -212,6 +212,9 @@ table 6923 "Expense User" key(WelcomeCorrelationKey; "Welcome Correlation Id") { } + key(EmployeeNo; "Employee No.") + { + } } var diff --git a/src/Apps/W1/ExpenseAgent/app/src/Permissions/AgentUserPermissions/ExpenseAgentDataDepen.PermissionSet.al b/src/Apps/W1/ExpenseAgent/app/src/Permissions/AgentUserPermissions/ExpenseAgentDataDepen.PermissionSet.al index be3c76eb855..44399cf55bd 100644 --- a/src/Apps/W1/ExpenseAgent/app/src/Permissions/AgentUserPermissions/ExpenseAgentDataDepen.PermissionSet.al +++ b/src/Apps/W1/ExpenseAgent/app/src/Permissions/AgentUserPermissions/ExpenseAgentDataDepen.PermissionSet.al @@ -20,6 +20,7 @@ using Microsoft.Finance.GeneralLedger.Ledger; using Microsoft.Finance.GeneralLedger.Reversal; using Microsoft.Finance.GeneralLedger.Setup; using Microsoft.Finance.SalesTax; +using Microsoft.Finance.SpendRequest; using Microsoft.Finance.VAT.Ledger; using Microsoft.Finance.VAT.Registration; using Microsoft.Finance.VAT.Reporting; @@ -91,6 +92,9 @@ permissionset 6955 "Expense Agent - Data Depen." tabledata "Accounting Period" = r, tabledata "Company Information" = R, tabledata "Gen. Journal Line" = R, + tabledata "Spend Request" = imd, + tabledata "Spend Request Detail" = imd, + tabledata "Spend Request To G/L Link" = d, tabledata "User Setup" = R, tabledata "Customer Posting Group" = R, tabledata "General Ledger Setup" = R, diff --git a/src/Apps/W1/ExpenseAgent/app/src/Permissions/AgentUserPermissions/ExpenseAgentObjects.PermissionSet.al b/src/Apps/W1/ExpenseAgent/app/src/Permissions/AgentUserPermissions/ExpenseAgentObjects.PermissionSet.al index 203c436e0e4..8505f96a828 100644 --- a/src/Apps/W1/ExpenseAgent/app/src/Permissions/AgentUserPermissions/ExpenseAgentObjects.PermissionSet.al +++ b/src/Apps/W1/ExpenseAgent/app/src/Permissions/AgentUserPermissions/ExpenseAgentObjects.PermissionSet.al @@ -62,6 +62,7 @@ permissionset 6952 "Expense Agent - Objects" page "Travel Requests API" = X, page "Travel Request Details API" = X, page "Travelers API" = X, + query "Travel Request Employees" = X, page "Tenant Feedback Setting API" = X, page "Expense Projects API" = X, page "Exp. Policies To Eval API" = X, @@ -160,6 +161,7 @@ permissionset 6952 "Expense Agent - Objects" codeunit "Expense Report Manual Reopen" = X, codeunit "Expense Rule Validation" = X, codeunit "Expense Total Caption Class" = X, + codeunit "Travel Request Approval" = X, codeunit "Expense Vendor Matching" = X, codeunit "Exp. Attach. Buffer Handler" = X, codeunit "Exp. Preview Post. Subscriber" = X, diff --git a/src/Apps/W1/ExpenseAgent/app/src/Permissions/ExpenseManagementObjects.permissionset.al b/src/Apps/W1/ExpenseAgent/app/src/Permissions/ExpenseManagementObjects.PermissionSet.al similarity index 99% rename from src/Apps/W1/ExpenseAgent/app/src/Permissions/ExpenseManagementObjects.permissionset.al rename to src/Apps/W1/ExpenseAgent/app/src/Permissions/ExpenseManagementObjects.PermissionSet.al index ca4cfbf7b68..0564a498728 100644 --- a/src/Apps/W1/ExpenseAgent/app/src/Permissions/ExpenseManagementObjects.permissionset.al +++ b/src/Apps/W1/ExpenseAgent/app/src/Permissions/ExpenseManagementObjects.PermissionSet.al @@ -168,6 +168,7 @@ permissionset 6904 "Expense Management - Objects" page "Travel Request Card" = X, page "Travel Request Subform" = X, page "Travel Request List" = X, + codeunit "Travel Request Approval" = X, codeunit "Expense Attachment Mgt." = X, codeunit "Expense Auto Population" = X, codeunit "Expense Event Subscriber" = X, diff --git a/src/Apps/W1/ExpenseAgent/app/src/Setup/Codeunits/ExpenseEventSubscriber.Codeunit.al b/src/Apps/W1/ExpenseAgent/app/src/Setup/Codeunits/ExpenseEventSubscriber.Codeunit.al index 4bf92c33a99..5b5c453ef6c 100644 --- a/src/Apps/W1/ExpenseAgent/app/src/Setup/Codeunits/ExpenseEventSubscriber.Codeunit.al +++ b/src/Apps/W1/ExpenseAgent/app/src/Setup/Codeunits/ExpenseEventSubscriber.Codeunit.al @@ -316,6 +316,7 @@ codeunit 6908 "Expense Event Subscriber" local procedure AutoApproveSpendRequestWhenAgentDisabled(var SpendRequest: Record "Spend Request") var ExpenseAgentSetup: Record "Expense Agent Setup"; + TravelRequestApproval: Codeunit "Travel Request Approval"; begin if SpendRequest."Document Type" <> SpendRequest."Document Type"::"Travel Request" then exit; @@ -328,7 +329,6 @@ codeunit 6908 "Expense Event Subscriber" if ExpenseAgentSetup."Enable Agent" then exit; - SpendRequest.Status := SpendRequest.Status::Approved; - SpendRequest.Modify(); + TravelRequestApproval.ApproveAutomatically(SpendRequest); end; } \ No newline at end of file diff --git a/src/Apps/W1/ExpenseAgent/app/src/Travel Request/Codeunits/TravelRequestApproval.Codeunit.al b/src/Apps/W1/ExpenseAgent/app/src/Travel Request/Codeunits/TravelRequestApproval.Codeunit.al new file mode 100644 index 00000000000..3c68d644e6e --- /dev/null +++ b/src/Apps/W1/ExpenseAgent/app/src/Travel Request/Codeunits/TravelRequestApproval.Codeunit.al @@ -0,0 +1,249 @@ +// ------------------------------------------------------------------------------------------------ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. See License.txt in the project root for license information. +// ------------------------------------------------------------------------------------------------ +namespace Microsoft.ExpenseAgent; + +using Microsoft.Finance.SpendRequest; +using System.Telemetry; +using System.Text; + +codeunit 7133 "Travel Request Approval" +{ + Access = Internal; + Permissions = tabledata "Spend Request" = rm, + tabledata "Expense Report Header" = ri, + tabledata "Posted Expense Report Header" = r, + tabledata "Posted Expense Report Line" = r; + + internal procedure Submit(var SpendRequest: Record "Spend Request"; SubmitterExpenseUserNo: Code[20]) + var + Submitter: Record "Expense User"; + ExpenseAgentSetup: Record "Expense Agent Setup"; + ReleaseSpendRequest: Codeunit "Release Spend Request"; + begin + CheckTravelRequest(SpendRequest); + SpendRequest.TestStatus(SpendRequest.Status::Open); + Submitter.SetLoadFields("Employee No."); + Submitter.Get(SubmitterExpenseUserNo); + Submitter.TestField("Employee No."); + if Submitter."Employee No." <> SpendRequest."Requested By" then + Error(NotTravelRequestOwnerErr, SubmitterExpenseUserNo, SpendRequest."No."); + + SpendRequest."Submitted By Expense User No." := SubmitterExpenseUserNo; + SpendRequest."Submitted At" := CurrentDateTime(); + Clear(SpendRequest."Approval Expense User No."); + Clear(SpendRequest."Rejection Reason"); + SpendRequest.Modify(); + ReleaseSpendRequest.Release(SpendRequest); + FeatureTelemetry.LogUsage('0000VEY', ExpenseAgentSetup.GetFeatureName(), TravelRequestSubmittedLbl); + end; + + internal procedure Approve(var SpendRequest: Record "Spend Request"; ApproverExpenseUserNo: Code[20]) + var + Approver: Record "Expense User"; + ExpenseAgentSetup: Record "Expense Agent Setup"; + begin + CheckTravelRequest(SpendRequest); + SpendRequest.TestStatus(SpendRequest.Status::Released); + CheckApprover(SpendRequest, ApproverExpenseUserNo, Approver); + ApproveInternal(SpendRequest, ApproverExpenseUserNo); + FeatureTelemetry.LogUsage('0000VEZ', ExpenseAgentSetup.GetFeatureName(), TravelRequestApprovedLbl); + end; + + internal procedure ApproveAutomatically(var SpendRequest: Record "Spend Request") + var + ExpenseAgentSetup: Record "Expense Agent Setup"; + begin + CheckTravelRequest(SpendRequest); + SpendRequest.TestStatus(SpendRequest.Status::Released); + + ExpenseAgentSetup.GetRecordOnce(); + if ExpenseAgentSetup."Enable Agent" then + Error(AutomaticApprovalNotAllowedErr); + + ApproveInternal(SpendRequest, ''); + FeatureTelemetry.LogUsage('0000VF0', ExpenseAgentSetup.GetFeatureName(), TravelRequestAutoApprovedLbl); + end; + + local procedure ApproveInternal(var SpendRequest: Record "Spend Request"; ApproverExpenseUserNo: Code[20]) + var + ExpenseReportHeader: Record "Expense Report Header"; + ExpenseAgentSetup: Record "Expense Agent Setup"; + begin + SpendRequest.TestField("Requested For"); + SpendRequest.Status := SpendRequest.Status::Approved; + SpendRequest."Approved/Rejected At" := CurrentDateTime(); + SpendRequest."Approved/Rejected by User ID" := UserSecurityId(); + SpendRequest."Approval Expense User No." := ApproverExpenseUserNo; + Clear(SpendRequest."Rejection Reason"); + SpendRequest.Modify(); + if ExpenseReportHeader.HasPostedTravelRequestReport(SpendRequest) then + exit; + + ExpenseReportHeader.CreateFromApprovedTravelRequest(SpendRequest); + ExpenseReportHeader.SetRange("Spend Request No.", SpendRequest."No."); + ExpenseReportHeader.SetRange("Expense User No.", SpendRequest."Requested For"); + if ExpenseReportHeader.IsEmpty() then begin + FeatureTelemetry.LogError('0000VEX', ExpenseAgentSetup.GetFeatureName(), ExpenseReportCreationFailedLbl, ExpenseReportCreationFailedTelemetryErr); + Error(GetExpenseReportWasNotCreatedError(SpendRequest)); + end; + end; + + internal procedure Reject(var SpendRequest: Record "Spend Request"; ApproverExpenseUserNo: Code[20]; RejectReason: Text) + var + Approver: Record "Expense User"; + ExpenseAgentSetup: Record "Expense Agent Setup"; + begin + CheckTravelRequest(SpendRequest); + SpendRequest.TestStatus(SpendRequest.Status::Released); + CheckApprover(SpendRequest, ApproverExpenseUserNo, Approver); + SpendRequest.Status := SpendRequest.Status::Rejected; + SpendRequest."Approved/Rejected At" := CurrentDateTime(); + SpendRequest."Approved/Rejected by User ID" := UserSecurityId(); + SpendRequest."Approval Expense User No." := ApproverExpenseUserNo; + SpendRequest."Rejection Reason" := CopyStr(RejectReason, 1, MaxStrLen(SpendRequest."Rejection Reason")); + SpendRequest.Modify(); + FeatureTelemetry.LogUsage('0000VF1', ExpenseAgentSetup.GetFeatureName(), TravelRequestRejectedLbl); + end; + + local procedure GetExpenseReportWasNotCreatedError(SpendRequest: Record "Spend Request"): ErrorInfo + var + ExpenseReportWasNotCreatedError: ErrorInfo; + begin + ExpenseReportWasNotCreatedError.Message := StrSubstNo( + ExpenseReportWasNotCreatedErr, SpendRequest."No.", SpendRequest."Requested For"); + ExpenseReportWasNotCreatedError.DataClassification := DataClassification::EndUserIdentifiableInformation; + ExpenseReportWasNotCreatedError.ErrorType := ErrorType::Internal; + exit(ExpenseReportWasNotCreatedError); + end; + + internal procedure ApplyOwnerFilter(var SpendRequest: Record "Spend Request"; OwnerSystemId: Guid): Code[20] + var + ExpenseUser: Record "Expense User"; + begin + ExpenseUser.SetLoadFields("Employee No."); + ExpenseUser.GetBySystemId(OwnerSystemId); + ExpenseUser.TestField("Employee No."); + // API ownership uses the expense user's GUID; the base table stores the linked employee number. + SpendRequest.SetRange("Requested By", ExpenseUser."Employee No."); + exit(ExpenseUser."Employee No."); + end; + + internal procedure ApplyApproverFilter(var SpendRequest: Record "Spend Request"; ApproverSystemId: Guid) + var + ExpenseUser: Record "Expense User"; + begin + ExpenseUser.SetLoadFields("No."); + ExpenseUser.GetBySystemId(ApproverSystemId); + ApplyApproverFilter(SpendRequest, ExpenseUser."No."); + end; + + internal procedure ApplyApproverFilter(var SpendRequest: Record "Spend Request"; ApproverExpenseUserNo: Code[20]) + var + Approver: Record "Expense User"; + RequestedForFilter: Text; + begin + CheckApproverPermissions(ApproverExpenseUserNo, Approver); + RequestedForFilter := GetRequestedForFilter(ApproverExpenseUserNo); + + SpendRequest.SetRange("Approver Expense User Filter"); + SpendRequest.SetRange(SystemId); + if RequestedForFilter = '' then + SpendRequest.SetRange(SystemId, CreateGuid()) + else + SpendRequest.SetFilter("Requested For", RequestedForFilter); + end; + + local procedure CheckTravelRequest(SpendRequest: Record "Spend Request") + begin + SpendRequest.TestField("Document Type", SpendRequest."Document Type"::"Travel Request"); + end; + + local procedure CheckApprover(SpendRequest: Record "Spend Request"; ApproverExpenseUserNo: Code[20]; var Approver: Record "Expense User") + var + ExpectedApproverExpenseUserNo: Code[20]; + begin + CheckApproverPermissions(ApproverExpenseUserNo, Approver); + ExpectedApproverExpenseUserNo := GetApproverExpenseUserNo(SpendRequest."Requested For"); + if ApproverExpenseUserNo <> ExpectedApproverExpenseUserNo then + Error(NotTravelRequestApproverErr, ApproverExpenseUserNo, SpendRequest."No."); + end; + + local procedure CheckApproverPermissions(ApproverExpenseUserNo: Code[20]; var Approver: Record "Expense User") + begin + Approver.SetLoadFields("Can Approve", "User Id For Approvals"); + Approver.Get(ApproverExpenseUserNo); + Approver.TestField("Can Approve", true); + Approver.TestField("User Id For Approvals"); + end; + + local procedure GetApproverExpenseUserNo(RequestedForExpenseUserNo: Code[20]): Code[20] + var + ExpenseApprovalSetup: Record "Expense Approval Setup"; + ExpenseAgentSetup: Record "Expense Agent Setup"; + begin + if ExpenseApprovalSetup.Get(RequestedForExpenseUserNo) then + if ExpenseApprovalSetup."Approver No." <> '' then + exit(ExpenseApprovalSetup."Approver No."); + + ExpenseAgentSetup.GetRecordOnce(); + exit(ExpenseAgentSetup."Default Approver No."); + end; + + local procedure GetRequestedForFilter(ApproverExpenseUserNo: Code[20]) RequestedForFilter: Text + var + ExpenseApprovalSetup: Record "Expense Approval Setup"; + ExpenseAgentSetup: Record "Expense Agent Setup"; + SelectionFilterManagement: Codeunit SelectionFilterManagement; + RecRef: RecordRef; + begin + ExpenseApprovalSetup.SetCurrentKey("Approver No."); + ExpenseApprovalSetup.SetRange("Approver No.", ApproverExpenseUserNo); + RecRef.GetTable(ExpenseApprovalSetup); + RequestedForFilter := SelectionFilterManagement.GetSelectionFilter(RecRef, ExpenseApprovalSetup.FieldNo("Expense User No.")); + + ExpenseAgentSetup.GetRecordOnce(); + if ExpenseAgentSetup."Default Approver No." = ApproverExpenseUserNo then + AppendDefaultSubmitters(RequestedForFilter); + + if StrLen(RequestedForFilter) > 2000 then + Error(TooManyTravelRequestSubmittersErr, ApproverExpenseUserNo); + end; + + local procedure AppendDefaultSubmitters(var RequestedForFilter: Text) + var + ExpenseUser: Record "Expense User"; + SelectionFilterManagement: Codeunit SelectionFilterManagement; + DefaultFilter: TextBuilder; + begin + if RequestedForFilter <> '' then + DefaultFilter.Append(RequestedForFilter); + + ExpenseUser.SetAutoCalcFields("Approver No."); + ExpenseUser.SetFilter("Approver No.", '%1', ''); + ExpenseUser.SetLoadFields("No."); + if ExpenseUser.FindSet() then + repeat + if DefaultFilter.Length > 0 then + DefaultFilter.Append('|'); + DefaultFilter.Append(SelectionFilterManagement.AddQuotes(ExpenseUser."No.")); + until ExpenseUser.Next() = 0; + + RequestedForFilter := DefaultFilter.ToText(); + end; + + var + FeatureTelemetry: Codeunit "Feature Telemetry"; + AutomaticApprovalNotAllowedErr: Label 'Automatic travel request approval can be used only when the Expense Agent is disabled.'; + TravelRequestSubmittedLbl: Label 'Travel request submitted.', Locked = true; + TravelRequestApprovedLbl: Label 'Travel request approved.', Locked = true; + TravelRequestAutoApprovedLbl: Label 'Travel request automatically approved.', Locked = true; + TravelRequestRejectedLbl: Label 'Travel request rejected.', Locked = true; + NotTravelRequestOwnerErr: Label 'Expense user %1 cannot submit travel request %2 because the user did not create it.', Comment = '%1 = Expense user number, %2 = Travel request number'; + NotTravelRequestApproverErr: Label 'Expense user %1 is not authorized to approve or reject travel request %2.', Comment = '%1 = Expense user number, %2 = Travel request number'; + TooManyTravelRequestSubmittersErr: Label 'Expense user %1 is configured to approve too many travel request submitters. Refine the approval setup before listing pending travel requests.', Comment = '%1 = Expense user number'; + ExpenseReportWasNotCreatedErr: Label 'An expense report was not created after approving travel request %1 for expense user %2.', Comment = '%1 = Travel Request No., %2 = Expense User No.'; + ExpenseReportCreationFailedLbl: Label 'Create expense report after travel request approval failed', Locked = true; + ExpenseReportCreationFailedTelemetryErr: Label 'The approved travel request did not produce an expense report.', Locked = true; +} diff --git a/src/Apps/W1/ExpenseAgent/app/src/Travel Request/Pages/TravelRequestCard.Page.al b/src/Apps/W1/ExpenseAgent/app/src/Travel Request/Pages/TravelRequestCard.Page.al index eeb88cc66d0..9fa8784d6e8 100644 --- a/src/Apps/W1/ExpenseAgent/app/src/Travel Request/Pages/TravelRequestCard.Page.al +++ b/src/Apps/W1/ExpenseAgent/app/src/Travel Request/Pages/TravelRequestCard.Page.al @@ -14,6 +14,9 @@ page 7129 "Travel Request Card" ApplicationArea = Basic, Suite; SourceTable = "Spend Request"; SourceTableView = where("Document Type" = filter("Travel Request")); + Permissions = tabledata "Spend Request" = rimd, + tabledata "Spend Request Detail" = rmd, + tabledata "Spend Request To G/L Link" = rd; AboutTitle = 'About the travel request'; AboutText = 'A travel request captures the intent to travel, its purpose, expected cost, schedule, and travelers, so it can be reviewed and approved before any expense is incurred.'; diff --git a/src/Apps/W1/ExpenseAgent/app/src/Travel Request/Pages/TravelRequestList.Page.al b/src/Apps/W1/ExpenseAgent/app/src/Travel Request/Pages/TravelRequestList.Page.al index b0ebfa59545..ca2ec8b49f1 100644 --- a/src/Apps/W1/ExpenseAgent/app/src/Travel Request/Pages/TravelRequestList.Page.al +++ b/src/Apps/W1/ExpenseAgent/app/src/Travel Request/Pages/TravelRequestList.Page.al @@ -17,6 +17,9 @@ page 7136 "Travel Request List" CardPageId = "Travel Request Card"; Editable = false; RefreshOnActivate = true; + Permissions = tabledata "Spend Request" = rd, + tabledata "Spend Request Detail" = rd, + tabledata "Spend Request To G/L Link" = rd; AboutTitle = 'About travel requests'; AboutText = 'A travel request captures the intent to travel, its purpose, expected cost, and travelers, so it can be reviewed and approved before any expense is incurred.'; diff --git a/src/Apps/W1/ExpenseAgent/app/src/Travel Request/Pages/TravelRequestSubform.Page.al b/src/Apps/W1/ExpenseAgent/app/src/Travel Request/Pages/TravelRequestSubform.Page.al index 71d3ad720e0..0347ef6f3ec 100644 --- a/src/Apps/W1/ExpenseAgent/app/src/Travel Request/Pages/TravelRequestSubform.Page.al +++ b/src/Apps/W1/ExpenseAgent/app/src/Travel Request/Pages/TravelRequestSubform.Page.al @@ -14,6 +14,8 @@ page 7137 "Travel Request Subform" SourceTable = "Spend Request Detail"; AutoSplitKey = true; DelayedInsert = true; + Permissions = tabledata "Spend Request Detail" = rimd, + tabledata "Spend Request" = rm; layout { diff --git a/src/Apps/W1/ExpenseAgent/app/src/Travel Request/Queries/TravelRequestEmployees.Query.al b/src/Apps/W1/ExpenseAgent/app/src/Travel Request/Queries/TravelRequestEmployees.Query.al new file mode 100644 index 00000000000..4c2623afcc8 --- /dev/null +++ b/src/Apps/W1/ExpenseAgent/app/src/Travel Request/Queries/TravelRequestEmployees.Query.al @@ -0,0 +1,38 @@ +// ------------------------------------------------------------------------------------------------ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. See License.txt in the project root for license information. +// ------------------------------------------------------------------------------------------------ +namespace Microsoft.ExpenseAgent; + +using Microsoft.Finance.SpendRequest; + +query 7111 "Travel Request Employees" +{ + Access = Internal; + Permissions = tabledata "Spend Request" = r, + tabledata Traveler = r, + tabledata "Expense User" = r; + + elements + { + dataitem(spendRequest; "Spend Request") + { + DataItemTableFilter = "Document Type" = const("Travel Request"); + filter(travelRequestSystemId; SystemId) { } + + dataitem(traveler; Traveler) + { + DataItemLink = "Spend Request No." = spendRequest."No."; + SqlJoinType = InnerJoin; + + dataitem(expenseUser; "Expense User") + { + DataItemLink = "No." = traveler."Expense User No."; + SqlJoinType = InnerJoin; + + column(employeeNo; "Employee No.") { } + } + } + } + } +} diff --git a/src/Apps/W1/ExpenseAgent/app/src/Travel Request/Tables/Traveler.Table.al b/src/Apps/W1/ExpenseAgent/app/src/Travel Request/Tables/Traveler.Table.al index c24a731531b..b5faafce02c 100644 --- a/src/Apps/W1/ExpenseAgent/app/src/Travel Request/Tables/Traveler.Table.al +++ b/src/Apps/W1/ExpenseAgent/app/src/Travel Request/Tables/Traveler.Table.al @@ -58,6 +58,13 @@ table 6938 Traveler TestStatusOpenOfSpendRequest(); end; } + field(6; "Employee No."; Code[20]) + { + Caption = 'Employee No.'; + FieldClass = FlowField; + CalcFormula = lookup("Expense User"."Employee No." where("No." = field("Expense User No."))); + ToolTip = 'Specifies the employee number linked to the expense user who is traveling.'; + } } keys @@ -87,6 +94,22 @@ table 6938 Traveler var DuplicateTravelerErr: Label 'Traveler %1 is already on this travel request. Each traveler can be added only once. Choose a different traveler or remove the existing line.', Comment = '%1 = Traveler No.'; + ExpenseUserNotFoundErr: Label 'No expense user is linked to employee %1.', Comment = '%1 = Employee No.'; + + internal procedure ValidateEmployeeNo(EmployeeNo: Code[20]) + var + ExpenseUser: Record "Expense User"; + begin + if EmployeeNo = '' then + Error(ExpenseUserNotFoundErr, EmployeeNo); + + ExpenseUser.SetLoadFields("No."); + ExpenseUser.SetRange("Employee No.", EmployeeNo); + if not ExpenseUser.FindFirst() then + Error(ExpenseUserNotFoundErr, EmployeeNo); + + Rec.Validate("Expense User No.", ExpenseUser."No."); + end; local procedure TestStatusOpenOfSpendRequest() var diff --git a/src/Apps/W1/ExpenseAgent/test/src/API/ExpenseCapabilitiesAPITest.Codeunit.al b/src/Apps/W1/ExpenseAgent/test/src/API/ExpenseCapabilitiesAPITest.Codeunit.al index 67a0fb672f7..4436c873d54 100644 --- a/src/Apps/W1/ExpenseAgent/test/src/API/ExpenseCapabilitiesAPITest.Codeunit.al +++ b/src/Apps/W1/ExpenseAgent/test/src/API/ExpenseCapabilitiesAPITest.Codeunit.al @@ -23,6 +23,7 @@ codeunit 148318 "Expense Capabilities API Test" ServiceNameTok: Label 'expenseCapabilities', Locked = true; ActivityLogCapabilityNameTok: Label 'activityLog', Locked = true; ApprovalConversationCapabilityNameTok: Label 'approvalConversation', Locked = true; + TravelRequestCapabilityNameTok: Label 'travelRequest', Locked = true; [Test] procedure CapabilitiesProjectsEnabledViaAPI() @@ -97,6 +98,27 @@ codeunit 148318 "Expense Capabilities API Test" 'Response must contain an enabled approvalConversation capability row.'); end; + [Test] + procedure TravelRequestCapabilityEnabledViaAPI() + var + TargetURL: Text; + ResponseText: Text; + begin + // Excluded in BCApps CI until authenticated OData execution in a dedicated test company + // with disabled isolation is available; then remove this method's DisabledTest.json entry. + // [SCENARIO] Travel requests are advertised when the supporting APIs are installed. + Initialize(); + + // [WHEN] The expenseCapabilities collection is fetched through the API. + TargetURL := LibraryGraphMgt.CreateTargetURL('', Page::"Expense Capabilities API", ServiceNameTok); + LibraryGraphMgt.GetFromWebServiceAndCheckResponseCode(ResponseText, TargetURL, 200); + + // [THEN] TravelRequest is present and enabled. + Assert.IsTrue( + ResponseContainsCapabilityState(ResponseText, TravelRequestCapabilityNameTok, true), + 'Response must contain an enabled travelRequest capability row.'); + end; + [Test] procedure CapabilitiesProjectsDisabledViaAPI() var diff --git a/src/Apps/W1/ExpenseAgent/test/src/API/ExpenseUsersAPITest.Codeunit.al b/src/Apps/W1/ExpenseAgent/test/src/API/ExpenseUsersAPITest.Codeunit.al index f724ba018d3..f80c8eb6e80 100644 --- a/src/Apps/W1/ExpenseAgent/test/src/API/ExpenseUsersAPITest.Codeunit.al +++ b/src/Apps/W1/ExpenseAgent/test/src/API/ExpenseUsersAPITest.Codeunit.al @@ -5,22 +5,60 @@ namespace Microsoft.Test.ExpenseAgent; using Microsoft.ExpenseAgent; +using Microsoft.HumanResources.Employee; codeunit 148315 "Expense Users API Test" { Subtype = Test; TestType = IntegrationTest; + RequiredTestIsolation = Disabled; TestPermissions = Disabled; var Assert: Codeunit Assert; LibraryExpense: Codeunit "Library - Expense"; + LibraryHumanResource: Codeunit "Library - Human Resource"; LibraryUtility: Codeunit "Library - Utility"; LibraryGraphMgt: Codeunit "Library - Graph Mgt"; LibraryTestInitialize: Codeunit "Library - Test Initialize"; APITestAuthHelper: Codeunit "Expense API Test Auth Helper"; IsInitialized: Boolean; ServiceNameTok: Label 'expenseUsers', Locked = true; + EmployeesServiceNameTok: Label 'employees', Locked = true; + + [Test] + procedure EmployeesAPICanFilterExpenseUsers() + var + LinkedExpenseUser: Record "Expense User"; + UnlinkedEmployee: Record Employee; + TargetURL: Text; + ResponseText: Text; + LinkedEmployeeIdTxt: Text; + UnlinkedEmployeeIdTxt: Text; + begin + // [SCENARIO] The Employees API can be filtered to employees linked to Expense Users. + Initialize(); + + // [GIVEN] One linked employee and one employee without an Expense User. + LibraryExpense.CreateExpenseUser(LinkedExpenseUser); + LibraryHumanResource.CreateEmployee(UnlinkedEmployee); + Commit(); + + // [WHEN] The employee collection is filtered by isExpenseUser. + TargetURL := LibraryGraphMgt.CreateTargetURL('', Page::"Employees API", EmployeesServiceNameTok); + if StrPos(TargetURL, '?') <> 0 then + TargetURL += '&$filter=isExpenseUser eq true' + else + TargetURL += '?$filter=isExpenseUser eq true'; + LibraryGraphMgt.GetFromWebServiceAndCheckResponseCode(ResponseText, TargetURL, 200); + ResponseText := LowerCase(ResponseText); + UnlinkedEmployeeIdTxt := LowerCase(LibraryGraphMgt.StripBrackets(Format(UnlinkedEmployee.SystemId))); + LinkedEmployeeIdTxt := GetEmployeeSystemIdText(LinkedExpenseUser."Employee No."); + + // [THEN] Only the employee linked to an Expense User is returned. + Assert.AreNotEqual(0, StrPos(ResponseText, LinkedEmployeeIdTxt), 'The linked employee must be returned.'); + Assert.AreEqual(0, StrPos(ResponseText, UnlinkedEmployeeIdTxt), 'The unlinked employee must not be returned.'); + end; [Test] procedure UnlinkedExpenseUserIsHiddenFromAPI() @@ -98,4 +136,12 @@ codeunit 148315 "Expense Users API Test" Commit(); LibraryTestInitialize.OnAfterTestSuiteInitialize(Codeunit::"Expense Users API Test"); end; + + local procedure GetEmployeeSystemIdText(EmployeeNo: Code[20]): Text + var + Employee: Record Employee; + begin + Employee.Get(EmployeeNo); + exit(LowerCase(LibraryGraphMgt.StripBrackets(Format(Employee.SystemId)))); + end; } diff --git a/src/Apps/W1/ExpenseAgent/test/src/API/TravelRequestsAPITest.Codeunit.al b/src/Apps/W1/ExpenseAgent/test/src/API/TravelRequestsAPITest.Codeunit.al new file mode 100644 index 00000000000..1f79225b17f --- /dev/null +++ b/src/Apps/W1/ExpenseAgent/test/src/API/TravelRequestsAPITest.Codeunit.al @@ -0,0 +1,1107 @@ +// ------------------------------------------------------------------------------------------------ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. See License.txt in the project root for license information. +// ------------------------------------------------------------------------------------------------ +namespace Microsoft.Test.ExpenseAgent; + +using Microsoft.ExpenseAgent; +using Microsoft.Finance.Currency; +using Microsoft.Finance.GeneralLedger.Setup; +using Microsoft.Finance.SpendRequest; +using Microsoft.HumanResources.Employee; + +// These HTTP tests remain excluded in Expense_Agent_Tests.DisabledTest.json until the +// authentication prerequisite provides an authenticated OData endpoint and a dedicated +// test company with committed fixtures and disabled test isolation. +// In-process employee filtering, traveler mapping/navigation, lifecycle, date, and scope coverage +// in "Spend Request Test", and restrictive role coverage in "Expense Permissions Test", remain enabled. +// Only the HTTP scenarios are excluded. +codeunit 148347 "Travel Requests API Test" +{ + Subtype = Test; + TestType = IntegrationTest; + RequiredTestIsolation = Disabled; + TestPermissions = Disabled; + + var + Assert: Codeunit Assert; + LibraryExpense: Codeunit "Library - Expense"; + LibraryERM: Codeunit "Library - ERM"; + LibraryGraphMgt: Codeunit "Library - Graph Mgt"; + LibraryHumanResource: Codeunit "Library - Human Resource"; + LibraryTestInitialize: Codeunit "Library - Test Initialize"; + APITestAuthHelper: Codeunit "Expense API Test Auth Helper"; + IsInitialized: Boolean; + ExpenseUsersServiceNameTok: Label 'expenseUsers', Locked = true; +#if not CLEAN29 + SpendRequestsServiceNameTok: Label 'spendRequests', Locked = true; +#endif + ApproverViewsServiceNameTok: Label 'approverViews', Locked = true; + TravelRequestsServiceNameTok: Label 'travelRequests', Locked = true; + TravelersServiceNameTok: Label 'travelers', Locked = true; + ApproveTravelRequestActionTok: Label 'Microsoft.NAV.approveTravelRequest', Locked = true; + CreateExpenseReportActionTok: Label 'Microsoft.NAV.createExpenseReport', Locked = true; + ExpenseReportsServiceNameTok: Label 'expenseReports', Locked = true; + TravelRequestDetailsServiceNameTok: Label 'travelRequestDetails', Locked = true; + BadRequestResponseErr: Label 'Response code is 400 (BadRequest).', Locked = true; + RequestedByCannotBeChangedErr: Label 'cannot be changed', Locked = true; + RequestedByRequestBodyLbl: Label '{"requestedBy":"%1"}', Comment = '%1 = Employee number', Locked = true; + ApproveTravelRequestBodyLbl: Label '{"approverExpenseUserNo":"%1"}', Comment = '%1 = Approver Expense User No.', Locked = true; + StatusRequestBodyLbl: Label '{"status":"Released"}', Locked = true; + StatusReadOnlyErr: Label 'Control ''status'' is read-only.', Locked = true; + InvalidTravelRequestDatesErr: Label 'Expected End Date cannot be before Expected Start Date.', Locked = true; + ExpenseUserNotLinkedErr: Label 'No expense user is linked to employee %1.', Comment = '%1 = Employee No.'; + StatusNotOpenErr: Label 'must have the status', Locked = true; + + [Test] + procedure TravelersAPIMapsEmployeeNumberToExpenseUser() + var + Employee: Record Employee; + OtherEmployee: Record Employee; + ExpenseUser: Record "Expense User"; + OtherExpenseUser: Record "Expense User"; + RequestedForExpenseUser: Record "Expense User"; + TravelRequest: Record "Spend Request"; + Traveler: Record Traveler; + RequestedForTraveler: Record Traveler; + Request: JsonObject; + Response: JsonObject; + ExpandedTravelers: JsonToken; + ExpandedTraveler: JsonToken; + ExpandedTravelerId: JsonToken; + ExpectedEmployeeNumbers: Dictionary of [Text, Code[20]]; + ExpectedEmployeeNo: Code[20]; +#if not CLEAN29 + ExpenseUserNo: JsonToken; + ExpenseUserName: JsonToken; +#endif + TargetURL: Text; + RequestBody: Text; + ResponseText: Text; + begin + // [SCENARIO] Direct and expanded reads return employee numbers for automatically and explicitly added travelers. + Initialize(); + + // [GIVEN] Requested For creates a traveler without writing employeeNumber through the Travelers API. + LibraryExpense.CreateExpenseUser(RequestedForExpenseUser); + LibraryExpense.CreateExpenseUser(ExpenseUser); + LibraryExpense.CreateExpenseUser(OtherExpenseUser); + Assert.AreNotEqual(RequestedForExpenseUser."No.", RequestedForExpenseUser."Employee No.", 'The requested-for fixture must use distinct Expense User and Employee numbers.'); + Assert.AreNotEqual(ExpenseUser."No.", ExpenseUser."Employee No.", 'The explicit traveler fixture must use distinct Expense User and Employee numbers.'); + CreateTravelRequest(TravelRequest, RequestedForExpenseUser."Employee No."); + TravelRequest.Validate("Requested For", RequestedForExpenseUser."No."); + TravelRequest.Modify(true); + RequestedForTraveler.SetRange("Spend Request No.", TravelRequest."No."); + RequestedForTraveler.FindFirst(); + RequestedForTraveler.TestField("Expense User No.", RequestedForExpenseUser."No."); + Request.Add('employeeNumber', ExpenseUser."Employee No."); + Request.WriteTo(RequestBody); + Commit(); + + // [WHEN] The employee is added through the Travelers API. + TargetURL := LibraryGraphMgt.CreateTargetURL( + Format(TravelRequest.SystemId), Page::"Travel Requests API", TravelRequestsServiceNameTok); + TargetURL := AppendPathToAPIURL(TargetURL, '/' + TravelersServiceNameTok); + LibraryGraphMgt.PostToWebServiceAndCheckResponseCode(TargetURL, RequestBody, ResponseText, 201); + + // [THEN] The Traveler stores the corresponding Expense User number. + Traveler.SetRange("Spend Request No.", TravelRequest."No."); + Traveler.SetFilter("Line No.", '<>%1', RequestedForTraveler."Line No."); + Traveler.FindFirst(); + Traveler.TestField("Expense User No.", ExpenseUser."No."); + + // [THEN] The API returns the employee mapping, retaining compatibility fields until removal. + Response.ReadFrom(ResponseText); + AssertTravelerEmployeeNumber(Response, ExpenseUser."Employee No."); +#if not CLEAN29 + Response.Get('expenseUserNo', ExpenseUserNo); + Response.Get('expenseUserName', ExpenseUserName); + Assert.AreEqual(ExpenseUser."No.", ExpenseUserNo.AsValue().AsText(), 'The obsolete Expense User number must remain compatible.'); + Assert.AreEqual(ExpenseUser.Name, ExpenseUserName.AsValue().AsText(), 'The obsolete Expense User name must remain compatible.'); +#else + Assert.IsFalse(Response.Contains('expenseUserNo'), 'Removed Expense User numbers must not be returned.'); + Assert.IsFalse(Response.Contains('expenseUserName'), 'Removed Expense User names must not be returned.'); +#endif + + // [THEN] Fresh direct GETs resolve both mappings independently of the POST input variable. + AssertTravelerGetEmployeeNumber(RequestedForTraveler.SystemId, RequestedForExpenseUser."Employee No."); + AssertTravelerGetEmployeeNumber(Traveler.SystemId, ExpenseUser."Employee No."); + + // [WHEN] The travel request is read with travelers and employees expanded. + TargetURL := LibraryGraphMgt.CreateTargetURL( + Format(TravelRequest.SystemId), Page::"Travel Requests API", TravelRequestsServiceNameTok); + if StrPos(TargetURL, '?') <> 0 then + TargetURL += '&$expand=travelers,employees' + else + TargetURL += '?$expand=travelers,employees'; + LibraryGraphMgt.GetFromWebServiceAndCheckResponseCode(ResponseText, TargetURL, 200); + + // [THEN] Each traveler itself returns the correct mapping, not just a nested employee entity. + ExpectedEmployeeNumbers.Add(LowerCase(LibraryGraphMgt.StripBrackets(Format(RequestedForTraveler.SystemId))), RequestedForExpenseUser."Employee No."); + ExpectedEmployeeNumbers.Add(LowerCase(LibraryGraphMgt.StripBrackets(Format(Traveler.SystemId))), ExpenseUser."Employee No."); + Response.ReadFrom(ResponseText); + Assert.IsTrue(Response.Get('travelers', ExpandedTravelers), 'The response must contain the travelers expansion.'); + foreach ExpandedTraveler in ExpandedTravelers.AsArray() do begin + Assert.IsTrue(ExpandedTraveler.AsObject().Get('id', ExpandedTravelerId), 'Each expanded traveler must have an id.'); + Assert.IsTrue( + ExpectedEmployeeNumbers.Get(LowerCase(ExpandedTravelerId.AsValue().AsText()), ExpectedEmployeeNo), + 'The response must not contain unexpected or duplicate travelers.'); + AssertTravelerEmployeeNumber(ExpandedTraveler.AsObject(), ExpectedEmployeeNo); + ExpectedEmployeeNumbers.Remove(LowerCase(ExpandedTravelerId.AsValue().AsText())); + end; + Assert.AreEqual(0, ExpectedEmployeeNumbers.Count(), 'The response must include both the requested-for and explicitly added travelers.'); +#if not CLEAN29 + Assert.AreNotEqual(0, StrPos(ResponseText, 'expenseUserNo'), 'Expanded travelers must retain the obsolete Expense User number.'); + Assert.AreNotEqual(0, StrPos(ResponseText, 'expenseUserName'), 'Expanded travelers must retain the obsolete Expense User name.'); +#else + Assert.AreEqual(0, StrPos(ResponseText, 'expenseUserNo'), 'Expanded travelers must not return removed Expense User numbers.'); + Assert.AreEqual(0, StrPos(ResponseText, 'expenseUserName'), 'Expanded travelers must not return removed Expense User names.'); +#endif + Employee.Get(ExpenseUser."Employee No."); + OtherEmployee.Get(OtherExpenseUser."Employee No."); + Assert.AreNotEqual( + 0, StrPos(LowerCase(ResponseText), LowerCase(LibraryGraphMgt.StripBrackets(Format(Employee.SystemId)))), + 'The traveler Employee entity must be returned.'); + Assert.AreEqual( + 0, StrPos(LowerCase(ResponseText), LowerCase(LibraryGraphMgt.StripBrackets(Format(OtherEmployee.SystemId)))), + 'Employees who are not travelers must not be returned.'); + end; + + [Test] + procedure TravelersAPIRejectsEmployeeWithoutExpenseUser() + var + Employee: Record Employee; + ExpenseUser: Record "Expense User"; + TravelRequest: Record "Spend Request"; + ErrorResponse: JsonToken; + ErrorMessage: JsonToken; + Request: JsonObject; + Response: JsonObject; + RequestBody: Text; + ResponseText: Text; + TargetURL: Text; + begin + // [SCENARIO] A traveler must be linked to an Expense User. + Initialize(); + + // [GIVEN] An employee without an Expense User and an open travel request. + LibraryHumanResource.CreateEmployee(Employee); + LibraryExpense.CreateExpenseUser(ExpenseUser); + CreateTravelRequest(TravelRequest, ExpenseUser."Employee No."); + Request.Add('employeeNumber', Employee."No."); + Request.WriteTo(RequestBody); + Commit(); + + // [WHEN] The employee is added through the Travelers API. + TargetURL := LibraryGraphMgt.CreateTargetURL( + Format(TravelRequest.SystemId), Page::"Travel Requests API", TravelRequestsServiceNameTok); + TargetURL := AppendPathToAPIURL(TargetURL, '/' + TravelersServiceNameTok); + asserterror LibraryGraphMgt.PostToWebServiceAndCheckResponseCode(TargetURL, RequestBody, ResponseText, 400); + + // [THEN] The API identifies the employee without an Expense User. + Assert.ExpectedError(BadRequestResponseErr); + Response.ReadFrom(ResponseText); + Response.Get('error', ErrorResponse); + ErrorResponse.AsObject().Get('message', ErrorMessage); + Assert.AreNotEqual( + 0, StrPos(ErrorMessage.AsValue().AsText(), StrSubstNo(ExpenseUserNotLinkedErr, Employee."No.")), + 'The response must identify the employee without an Expense User.'); + end; + + [Test] + procedure CreateExpenseReportActionRecreatesDeletedReport() + var + ExpenseReportHeader: Record "Expense Report Header"; + ExpenseUser: Record "Expense User"; + TravelRequest: Record "Spend Request"; + TargetURL: Text; + ResponseText: Text; + begin + // [SCENARIO] The bound OData action recreates a deleted report for an approved travel request. + Initialize(); + + // [GIVEN] An approved request whose automatically created report was deleted. + LibraryExpense.CreateExpenseUser(ExpenseUser); + CreateTravelRequest(TravelRequest, ExpenseUser."Employee No."); + TravelRequest.Validate("Requested For", ExpenseUser."No."); + TravelRequest.Modify(true); + LibraryExpense.SetSpendRequestStatus(TravelRequest, TravelRequest.Status::Approved); + ExpenseReportHeader.CreateFromApprovedTravelRequest(TravelRequest); + ExpenseReportHeader.SetRange("Spend Request No.", TravelRequest."No."); + ExpenseReportHeader.FindFirst(); + ExpenseReportHeader.Delete(true); + Commit(); + + // [WHEN] The create expense report action is invoked through the owner's OData route. + TargetURL := LibraryGraphMgt.CreateTargetURL( + Format(ExpenseUser.SystemId), Page::"Expense Users API", ExpenseUsersServiceNameTok); + TargetURL := AppendPathToAPIURL( + TargetURL, '/' + TravelRequestsServiceNameTok + '(' + + LibraryGraphMgt.StripBrackets(Format(TravelRequest.SystemId)) + ')/' + CreateExpenseReportActionTok); + LibraryGraphMgt.PostToWebServiceAndCheckResponseCode(TargetURL, '{}', ResponseText, 201); + + // [THEN] A new report is linked to the request and its Expense User. + ExpenseReportHeader.SetRange("Spend Request No.", TravelRequest."No."); + ExpenseReportHeader.FindFirst(); + ExpenseReportHeader.TestField("Expense User No.", ExpenseUser."No."); + end; + + [Test] + procedure ApproveTravelRequestActionCreatesExpenseReport() + var + ApprovalSetup: Record "Expense Approval Setup"; + ExpenseReportHeader: Record "Expense Report Header"; + ApproverExpenseUser: Record "Expense User"; + RequestedForExpenseUser: Record "Expense User"; + TravelRequest: Record "Spend Request"; + TargetURL: Text; + RequestBody: Text; + ResponseText: Text; + begin + // [SCENARIO] Approving a travel request through its bound OData action creates the expense report. + Initialize(); + + // [GIVEN] A released travel request assigned to an approver. + LibraryExpense.UpdateEnableAgentInAgentSetup(true); + LibraryExpense.CreateExpenseUser(RequestedForExpenseUser); + CreateApprover(ApproverExpenseUser); + LibraryExpense.CreateExpenseApprovalSetup( + ApprovalSetup, RequestedForExpenseUser."No.", ApproverExpenseUser."No."); + CreatePendingTravelRequest(TravelRequest, RequestedForExpenseUser); + Commit(); + + // [WHEN] The approve travel request action is invoked through OData. + TargetURL := LibraryGraphMgt.CreateTargetURLWithSubpage( + Format(TravelRequest.SystemId), Page::"Travel Requests API", + TravelRequestsServiceNameTok, ApproveTravelRequestActionTok); + RequestBody := StrSubstNo(ApproveTravelRequestBodyLbl, ApproverExpenseUser."No."); + LibraryGraphMgt.PostToWebServiceAndCheckResponseCode(TargetURL, RequestBody, ResponseText, 200); + + // [THEN] The request is approved and a report is created for Requested For. + TravelRequest.Get(TravelRequest."No."); + TravelRequest.TestField(Status, TravelRequest.Status::Approved); + ExpenseReportHeader.SetRange("Spend Request No.", TravelRequest."No."); + ExpenseReportHeader.FindFirst(); + ExpenseReportHeader.TestField("Expense User No.", RequestedForExpenseUser."No."); + end; + + [Test] + procedure TravelRequestsAPINormalizesCurrency() + var + ExpenseUser: Record "Expense User"; + Request: JsonObject; + TargetURL: Text; + begin + // [SCENARIO] The user-scoped header API maps LCY without bypassing currency validation. + Initialize(); + + // [GIVEN] A linked expense user creating a travel request. + LibraryExpense.CreateExpenseUser(ExpenseUser); + Request.Add('requestedBy', ExpenseUser."Employee No."); + TargetURL := LibraryGraphMgt.CreateTargetURL( + Format(ExpenseUser.SystemId), Page::"Expense Users API", ExpenseUsersServiceNameTok); + TargetURL := AppendPathToAPIURL(TargetURL, '/' + TravelRequestsServiceNameTok); + + // [WHEN] Currency is supplied, changed, cleared, and omitted over HTTP. + // [THEN] API and storage representations agree and table validation remains active. + VerifyTravelRequestCurrencyAPI(TargetURL, Request, false); + end; + + [Test] + procedure TravelRequestDetailsAPINormalizesCurrency() + var + ExpenseUser: Record "Expense User"; + TravelRequest: Record "Spend Request"; + Request: JsonObject; + TargetURL: Text; + begin + // [SCENARIO] The user-scoped detail API accepts LCY ISO codes and retains foreign-currency rules. + Initialize(); + + // [GIVEN] An open travel request owned by a linked expense user. + LibraryExpense.CreateExpenseUser(ExpenseUser); + CreateTravelRequest(TravelRequest, ExpenseUser."Employee No."); + TargetURL := LibraryGraphMgt.CreateTargetURL( + Format(ExpenseUser.SystemId), Page::"Expense Users API", ExpenseUsersServiceNameTok); + TargetURL := AppendPathToAPIURL( + TargetURL, '/' + TravelRequestsServiceNameTok + '(' + + LibraryGraphMgt.StripBrackets(Format(TravelRequest.SystemId)) + ')/' + TravelRequestDetailsServiceNameTok); + + // [WHEN] Currency is supplied, changed, cleared, and omitted over HTTP. + // [THEN] API and storage representations agree and table validation remains active. + VerifyTravelRequestCurrencyAPI(TargetURL, Request, true); + end; + + [Test] + procedure TravelRequestsAPIPreservesAndUpdatesDates() + var + ExpenseUser: Record "Expense User"; + Request: JsonObject; + Response: JsonObject; + ErrorResponse: JsonToken; + ErrorMessage: JsonToken; + RequestSystemId: Guid; + StartDate: Date; + EndDate: Date; + TargetURL: Text; + RecordURL: Text; + RequestBody: Text; + ResponseText: Text; + begin + // [SCENARIO] User-scoped POST and PATCH preserve and validate the final date pair. + Initialize(); + + // [GIVEN] A linked user and a future pair, with id first and end before start in the payload. + LibraryExpense.CreateExpenseUser(ExpenseUser); + RequestSystemId := CreateGuid(); + StartDate := WorkDate() + 30; + EndDate := WorkDate() + 33; + Request.Add('id', LibraryGraphMgt.StripBrackets(Format(RequestSystemId))); + Request.Add('requestedBy', ExpenseUser."Employee No."); + Request.Add('expectedEndDate', Format(EndDate, 0, 9)); + Request.Add('expectedStartDate', Format(StartDate, 0, 9)); + Request.WriteTo(RequestBody); + Commit(); + + // [WHEN] The request is created through user-scoped navigation. + TargetURL := LibraryGraphMgt.CreateTargetURL( + Format(ExpenseUser.SystemId), Page::"Expense Users API", ExpenseUsersServiceNameTok); + TargetURL := AppendPathToAPIURL(TargetURL, '/' + TravelRequestsServiceNameTok); + LibraryGraphMgt.PostToWebServiceAndCheckResponseCode(TargetURL, RequestBody, ResponseText, 201); + + // [THEN] POST, GET, and storage retain the supplied dates and identity. + VerifyTravelRequestDates(ResponseText, RequestSystemId, StartDate, EndDate); + RecordURL := AppendPathToAPIURL(TargetURL, '(' + LibraryGraphMgt.StripBrackets(Format(RequestSystemId)) + ')'); + LibraryGraphMgt.GetFromWebServiceAndCheckResponseCode(ResponseText, RecordURL, 200); + VerifyTravelRequestDates(ResponseText, RequestSystemId, StartDate, EndDate); + + // [WHEN] A start-first PATCH moves the range beyond the old end. + StartDate += 30; + EndDate += 30; + Clear(Request); + Request.Add('expectedStartDate', Format(StartDate, 0, 9)); + Request.Add('expectedEndDate', Format(EndDate, 0, 9)); + Request.WriteTo(RequestBody); + LibraryGraphMgt.PatchToWebServiceAndCheckResponseCode(RecordURL, RequestBody, ResponseText, 200); + + // [THEN] The complete later range is accepted. + VerifyTravelRequestDates(ResponseText, RequestSystemId, StartDate, EndDate); + + // [WHEN] An end-first PATCH moves the range before the old start. + StartDate := WorkDate() - 33; + EndDate := WorkDate() - 30; + Clear(Request); + Request.Add('expectedEndDate', Format(EndDate, 0, 9)); + Request.Add('expectedStartDate', Format(StartDate, 0, 9)); + Request.WriteTo(RequestBody); + LibraryGraphMgt.PatchToWebServiceAndCheckResponseCode(RecordURL, RequestBody, ResponseText, 200); + + // [THEN] The complete earlier range is accepted. + VerifyTravelRequestDates(ResponseText, RequestSystemId, StartDate, EndDate); + + // [WHEN] Only the end date is changed. + EndDate += 1; + Clear(Request); + Request.Add('expectedEndDate', Format(EndDate, 0, 9)); + Request.WriteTo(RequestBody); + LibraryGraphMgt.PatchToWebServiceAndCheckResponseCode(RecordURL, RequestBody, ResponseText, 200); + + // [THEN] The omitted start remains unchanged. + VerifyTravelRequestDates(ResponseText, RequestSystemId, StartDate, EndDate); + + // [WHEN] A start-only PATCH would exceed the stored end. + Clear(Request); + Request.Add('expectedStartDate', Format(EndDate + 1, 0, 9)); + Request.WriteTo(RequestBody); + asserterror LibraryGraphMgt.PatchToWebServiceAndCheckResponseCode(RecordURL, RequestBody, ResponseText, 400); + + // [THEN] The date-range error is returned and the previous valid pair remains stored. + Assert.ExpectedError(BadRequestResponseErr); + Assert.ExpectedErrorCode('Dialog'); + Response.ReadFrom(ResponseText); + Response.Get('error', ErrorResponse); + ErrorResponse.AsObject().Get('message', ErrorMessage); + Assert.AreNotEqual(0, StrPos(ErrorMessage.AsValue().AsText(), InvalidTravelRequestDatesErr), 'The invalid date range must cause the rejection.'); + LibraryGraphMgt.GetFromWebServiceAndCheckResponseCode(ResponseText, RecordURL, 200); + VerifyTravelRequestDates(ResponseText, RequestSystemId, StartDate, EndDate); + end; + + [Test] + procedure TravelRequestsAreScopedByEmployeeNumber() + var + ExpenseUser: Record "Expense User"; + OtherExpenseUser: Record "Expense User"; + TravelRequest: Record "Spend Request"; + OtherTravelRequest: Record "Spend Request"; + TargetURL: Text; + ResponseText: Text; + TravelRequestIdTxt: Text; + OtherTravelRequestIdTxt: Text; + begin + // [SCENARIO] Expense Users expose only travel requests owned by their linked employee. + Initialize(); + + // [GIVEN] Two expense users with requests owned by their distinct employee numbers. + LibraryExpense.CreateExpenseUser(ExpenseUser); + LibraryExpense.CreateExpenseUser(OtherExpenseUser); + Assert.AreNotEqual( + ExpenseUser."No.", ExpenseUser."Employee No.", + 'The test requires different Expense User and Employee numbers.'); + CreateTravelRequest(TravelRequest, ExpenseUser."Employee No."); + CreateTravelRequest(OtherTravelRequest, OtherExpenseUser."Employee No."); + Commit(); + + // [WHEN] The first user's travel requests are expanded through the API. + TargetURL := LibraryGraphMgt.CreateTargetURL( + Format(ExpenseUser.SystemId), Page::"Expense Users API", ExpenseUsersServiceNameTok); + if StrPos(TargetURL, '?') <> 0 then + TargetURL += '&$expand=travelRequests' + else + TargetURL += '?$expand=travelRequests'; + LibraryGraphMgt.GetFromWebServiceAndCheckResponseCode(ResponseText, TargetURL, 200); + ResponseText := LowerCase(ResponseText); + TravelRequestIdTxt := LowerCase(LibraryGraphMgt.StripBrackets(Format(TravelRequest.SystemId))); + OtherTravelRequestIdTxt := LowerCase(LibraryGraphMgt.StripBrackets(Format(OtherTravelRequest.SystemId))); + + // [THEN] Only the request owned by that employee is returned. + Assert.AreNotEqual( + 0, StrPos(ResponseText, TravelRequestIdTxt), + 'The Expense User should expose the Travel Request linked by Employee No.'); + Assert.AreEqual( + 0, StrPos(ResponseText, OtherTravelRequestIdTxt), + 'The Expense User should not expose another employee''s Travel Request.'); + + // [WHEN] The owner is renamed and the same GUID-based URL is requested. + ExpenseUser.Rename(CopyStr(Format(CreateGuid()), 1, MaxStrLen(ExpenseUser."No."))); + Commit(); + LibraryGraphMgt.GetFromWebServiceAndCheckResponseCode(ResponseText, TargetURL, 200); + ResponseText := LowerCase(ResponseText); + + // [THEN] The stable URL still includes only the original owner's request. + Assert.AreNotEqual(0, StrPos(ResponseText, TravelRequestIdTxt), 'Owner navigation must survive a business-number rename.'); + Assert.AreEqual(0, StrPos(ResponseText, OtherTravelRequestIdTxt), 'Renaming must not broaden the owner scope.'); + end; + + [Test] + procedure ExpenseReportAPIExposesLinkedTravelRequest() + var + ExpenseUser: Record "Expense User"; + ExpenseReportHeader: Record "Expense Report Header"; + TravelRequest: Record "Spend Request"; + Response: JsonObject; + TravelRequestId: JsonToken; + LinkedTravelRequest: JsonToken; + LinkedTravelRequestId: JsonToken; + TargetURL: Text; + ResponseText: Text; + ExpectedId: Text; + begin + // [SCENARIO] The report's projected GUID and expanded navigation identify the same approved travel request. + Initialize(); + + // [GIVEN] An expense report created from an approved travel request. + LibraryExpense.CreateExpenseUser(ExpenseUser); + CreateTravelRequest(TravelRequest, ExpenseUser."Employee No."); + TravelRequest.Validate("Requested For", ExpenseUser."No."); + TravelRequest.Modify(true); + LibraryExpense.SetSpendRequestStatus(TravelRequest, TravelRequest.Status::Approved); + ExpenseReportHeader.CreateFromApprovedTravelRequest(TravelRequest); + ExpenseReportHeader.SetRange("Spend Request No.", TravelRequest."No."); + ExpenseReportHeader.FindFirst(); + Commit(); + + // [WHEN] The report is retrieved with its travel request expanded. + TargetURL := LibraryGraphMgt.CreateTargetURL( + Format(ExpenseReportHeader.SystemId), Page::"Expense Reports API", ExpenseReportsServiceNameTok); + if StrPos(TargetURL, '?') <> 0 then + TargetURL += '&$expand=travelRequest' + else + TargetURL += '?$expand=travelRequest'; + LibraryGraphMgt.GetFromWebServiceAndCheckResponseCode(ResponseText, TargetURL, 200); + + // [THEN] The report's GUID and expanded request identify the originating travel request. + Response.ReadFrom(ResponseText); + Response.Get('travelRequestId', TravelRequestId); + Response.Get('travelRequest', LinkedTravelRequest); + LinkedTravelRequest.AsObject().Get('id', LinkedTravelRequestId); + ExpectedId := LowerCase(LibraryGraphMgt.StripBrackets(Format(TravelRequest.SystemId))); + Assert.AreEqual(ExpectedId, LowerCase(TravelRequestId.AsValue().AsText()), 'The report must expose the linked travel request GUID.'); + Assert.AreEqual(ExpectedId, LowerCase(LinkedTravelRequestId.AsValue().AsText()), 'The expanded navigation must return the linked travel request.'); + end; + + [Test] + procedure TravelRequestDetailsAPIExposesTypeAndCategory() + var + ExpenseUser: Record "Expense User"; + ExpenseCategory: Record "Expense Category"; + TravelRequest: Record "Spend Request"; + TravelRequestDetail: Record "Spend Request Detail"; + Response: JsonObject; + DetailType: JsonToken; + CategoryCode: JsonToken; + TargetURL: Text; + ResponseText: Text; + begin + // [SCENARIO] The detail API projects its line type and expense category. + Initialize(); + + // [GIVEN] A Category detail line with an expense category. + LibraryExpense.CreateExpenseUser(ExpenseUser); + CreateTravelRequest(TravelRequest, ExpenseUser."Employee No."); + LibraryExpense.CreateExpenseCategory( + ExpenseCategory, ExpenseCategory."Reimbursement Type"::"Employee Paid", ExpenseCategory."Expense Detail Required"::" "); + LibraryExpense.CreateSpendRequestDetail(TravelRequestDetail, TravelRequest."No.", 0); + TravelRequestDetail.Validate(Type, TravelRequestDetail.Type::Category); + TravelRequestDetail.Validate("Expense Category Code", ExpenseCategory.Code); + TravelRequestDetail.Modify(true); + Commit(); + + // [WHEN] The detail is retrieved through the API. + TargetURL := LibraryGraphMgt.CreateTargetURL( + Format(TravelRequestDetail.SystemId), Page::"Travel Request Details API", TravelRequestDetailsServiceNameTok); + LibraryGraphMgt.GetFromWebServiceAndCheckResponseCode(ResponseText, TargetURL, 200); + + // [THEN] The payload includes the stored type and expense category. + Response.ReadFrom(ResponseText); + Response.Get('type', DetailType); + Response.Get('expenseCategoryCode', CategoryCode); + Assert.AreEqual('Category', DetailType.AsValue().AsText(), 'The detail API must expose the Category line type.'); + Assert.AreEqual(ExpenseCategory.Code, CategoryCode.AsValue().AsText(), 'The detail API must expose the expense category code.'); + end; + + [Test] + procedure ApproverViewReturnsOnlyAssignedTravelRequests() + var + ApprovalSetup: Record "Expense Approval Setup"; + OtherApprovalSetup: Record "Expense Approval Setup"; + ApproverExpenseUser: Record "Expense User"; + OtherApproverExpenseUser: Record "Expense User"; + RequestedExpenseUser: Record "Expense User"; + OtherRequestedExpenseUser: Record "Expense User"; + AssignedTravelRequest: Record "Spend Request"; + OtherTravelRequest: Record "Spend Request"; + TargetURL: Text; + ResponseText: Text; + AssignedTravelRequestIdTxt: Text; + OtherTravelRequestIdTxt: Text; + begin + // [SCENARIO] Approver Views expose only pending travel requests assigned to the approver. + Initialize(); + + // [GIVEN] Two pending requests assigned to different approvers. + LibraryExpense.CreateExpenseUser(RequestedExpenseUser); + LibraryExpense.CreateExpenseUser(OtherRequestedExpenseUser); + CreateApprover(ApproverExpenseUser); + CreateApprover(OtherApproverExpenseUser); + LibraryExpense.CreateExpenseApprovalSetup( + ApprovalSetup, RequestedExpenseUser."No.", ApproverExpenseUser."No."); + LibraryExpense.CreateExpenseApprovalSetup( + OtherApprovalSetup, OtherRequestedExpenseUser."No.", OtherApproverExpenseUser."No."); + CreatePendingTravelRequest(AssignedTravelRequest, RequestedExpenseUser); + CreatePendingTravelRequest(OtherTravelRequest, OtherRequestedExpenseUser); + Commit(); + + // [WHEN] The first approver's travel requests are expanded through the API. + TargetURL := LibraryGraphMgt.CreateTargetURL( + Format(ApproverExpenseUser.SystemId), Page::"Approver View API", ApproverViewsServiceNameTok); + if StrPos(TargetURL, '?') <> 0 then + TargetURL += '&$expand=travelRequests' + else + TargetURL += '?$expand=travelRequests'; + LibraryGraphMgt.GetFromWebServiceAndCheckResponseCode(ResponseText, TargetURL, 200); + ResponseText := LowerCase(ResponseText); + AssignedTravelRequestIdTxt := LowerCase(LibraryGraphMgt.StripBrackets(Format(AssignedTravelRequest.SystemId))); + OtherTravelRequestIdTxt := LowerCase(LibraryGraphMgt.StripBrackets(Format(OtherTravelRequest.SystemId))); + + // [THEN] Only the request assigned to that approver is returned. + Assert.AreNotEqual( + 0, StrPos(ResponseText, AssignedTravelRequestIdTxt), + 'The Approver View should expose the Travel Request assigned to the approver.'); + Assert.AreEqual( + 0, StrPos(ResponseText, OtherTravelRequestIdTxt), + 'The Approver View should not expose a Travel Request assigned to another approver.'); + end; + + [Test] + procedure TravelRequestsAPIAllowsOwnerOnInsertAndUnchangedPatch() + var + ExpenseUser: Record "Expense User"; + OtherExpenseUser: Record "Expense User"; + TravelRequest: Record "Spend Request"; + Response: JsonObject; + RequestId: JsonToken; + ErrorResponse: JsonToken; + ErrorMessage: JsonToken; + TravelRequestSystemId: Guid; + RequestBody: Text; + ResponseText: Text; + TargetURL: Text; + begin + // [SCENARIO] The owner can be supplied on POST and resent unchanged on PATCH. + Initialize(); + + // [GIVEN] An expense user linked to an employee. + LibraryExpense.CreateExpenseUser(ExpenseUser); + LibraryExpense.CreateExpenseUser(OtherExpenseUser); + Commit(); + + // [WHEN] A travel request is created with that employee as its owner. + TargetURL := LibraryGraphMgt.CreateTargetURL( + Format(ExpenseUser.SystemId), Page::"Expense Users API", ExpenseUsersServiceNameTok); + TargetURL := AppendPathToAPIURL(TargetURL, '/' + TravelRequestsServiceNameTok); + + // [WHEN] POST attempts to assign another employee under this user's GUID. + RequestBody := StrSubstNo(RequestedByRequestBodyLbl, OtherExpenseUser."Employee No."); + asserterror LibraryGraphMgt.PostToWebServiceAndCheckResponseCode(TargetURL, RequestBody, ResponseText, 400); + + // [THEN] The owner mismatch is rejected before a request can be inserted. + Assert.ExpectedError(BadRequestResponseErr); + Assert.ExpectedErrorCode('Dialog'); + Response.ReadFrom(ResponseText); + Response.Get('error', ErrorResponse); + ErrorResponse.AsObject().Get('message', ErrorMessage); + Assert.AreNotEqual( + 0, StrPos(ErrorMessage.AsValue().AsText(), TravelRequest.FieldCaption("Requested By")), + 'The rejection must identify the owner mismatch.'); + + // [WHEN] POST supplies the employee matching this user's GUID. + RequestBody := StrSubstNo(RequestedByRequestBodyLbl, ExpenseUser."Employee No."); + LibraryGraphMgt.PostToWebServiceAndCheckResponseCode(TargetURL, RequestBody, ResponseText, 201); + + // [THEN] The new travel request stores the supplied owner. + Response.ReadFrom(ResponseText); + Response.Get('id', RequestId); + Evaluate(TravelRequestSystemId, RequestId.AsValue().AsText()); + SelectLatestVersion(); + TravelRequest.GetBySystemId(TravelRequestSystemId); + Assert.AreEqual(ExpenseUser."Employee No.", TravelRequest."Requested By", 'POST must accept the travel request owner.'); + Assert.AreEqual(TravelRequest."Document Type"::"Travel Request", TravelRequest."Document Type", 'POST must create a travel request.'); + + // [WHEN] The purpose is updated while resending the same owner. + // [THEN] PATCH succeeds without changing the owner. + TargetURL := AppendPathToAPIURL(TargetURL, '(' + LibraryGraphMgt.StripBrackets(Format(TravelRequest.SystemId)) + ')'); + AssertOwnerPreservingPatch(TargetURL, TravelRequest); + SelectLatestVersion(); + TravelRequest.GetBySystemId(TravelRequestSystemId); + Assert.AreEqual(ExpenseUser."Employee No.", TravelRequest."Requested By", 'PATCH must preserve the stored owner.'); + Assert.AreEqual('Updated business trip', TravelRequest.Purpose, 'PATCH must persist the updated purpose.'); + end; + + [Test] + procedure TravelRequestsAPIRejectsLifecycleFieldChanges() + var + ExpenseUser: Record "Expense User"; + OtherExpenseUser: Record "Expense User"; + TravelRequest: Record "Spend Request"; + OriginalRequestedBy: Code[20]; + TravelRequestSystemId: Guid; + Response: JsonObject; + ErrorResponse: JsonToken; + ErrorCode: JsonToken; + ErrorMessage: JsonToken; + RequestBody: Text; + ResponseText: Text; + TargetURL: Text; + begin + // [SCENARIO] PATCH cannot reassign a travel request or change its status. + Initialize(); + + // [GIVEN] An open travel request and another expense user. + LibraryExpense.CreateExpenseUser(ExpenseUser); + LibraryExpense.CreateExpenseUser(OtherExpenseUser); + CreateTravelRequest(TravelRequest, ExpenseUser."Employee No."); + OriginalRequestedBy := TravelRequest."Requested By"; + TravelRequestSystemId := TravelRequest.SystemId; + Commit(); + + // [WHEN] PATCH attempts to change the owner. + TargetURL := LibraryGraphMgt.CreateTargetURL( + Format(TravelRequest.SystemId), Page::"Travel Requests API", TravelRequestsServiceNameTok); + RequestBody := StrSubstNo(RequestedByRequestBodyLbl, OtherExpenseUser."Employee No."); + asserterror LibraryGraphMgt.PatchToWebServiceAndCheckResponseCode(TargetURL, RequestBody, ResponseText, 400); + + // [THEN] The API identifies the immutable owner and the affected request. + Assert.ExpectedError(BadRequestResponseErr); + Assert.ExpectedErrorCode('Dialog'); + AssertOwnerChangeError(ResponseText, TravelRequest); + + // [WHEN] PATCH attempts to change the status. + Clear(ResponseText); + asserterror LibraryGraphMgt.PatchToWebServiceAndCheckResponseCode( + TargetURL, StatusRequestBodyLbl, ResponseText, 400); + + // [THEN] The API rejects the read-only status, leaving ownership and status unchanged. + Assert.ExpectedError(BadRequestResponseErr); + Assert.ExpectedErrorCode('Dialog'); + Response.ReadFrom(ResponseText); + Response.Get('error', ErrorResponse); + ErrorResponse.AsObject().Get('code', ErrorCode); + ErrorResponse.AsObject().Get('message', ErrorMessage); + Assert.AreEqual('BadRequest', ErrorCode.AsValue().AsText(), 'The status update must be rejected by the OData read-only guard.'); + Assert.AreNotEqual(0, StrPos(ErrorMessage.AsValue().AsText(), StatusReadOnlyErr), 'The API error must identify the read-only status control.'); + SelectLatestVersion(); + TravelRequest.GetBySystemId(TravelRequestSystemId); + Assert.AreEqual( + OriginalRequestedBy, TravelRequest."Requested By", + 'The Travel Requests API must not change the Travel Request owner.'); + Assert.AreEqual( + TravelRequest.Status::Open, TravelRequest.Status, + 'The Travel Requests API must not change the Travel Request status.'); + end; + +#if not CLEAN29 + [Test] + procedure LegacySpendRequestsAPIAllowsUnchangedOwner() + var + ExpenseUser: Record "Expense User"; + TravelRequest: Record "Spend Request"; + TargetURL: Text; + begin + // [SCENARIO] Legacy clients may resend the unchanged owner when updating a travel request. + Initialize(); + + // [GIVEN] A travel request owned by a linked employee. + LibraryExpense.CreateExpenseUser(ExpenseUser); + CreateTravelRequest(TravelRequest, ExpenseUser."Employee No."); + Commit(); + + // [WHEN] The purpose is updated through the legacy endpoint with the same owner. + // [THEN] PATCH succeeds without changing the owner. + TargetURL := LibraryGraphMgt.CreateTargetURL( + Format(TravelRequest.SystemId), Page::"Spend Requests API", SpendRequestsServiceNameTok); + AssertOwnerPreservingPatch(TargetURL, TravelRequest); + end; + + [Test] + procedure LegacySpendRequestsAPIRejectsTravelRequestOwnerChange() + var + ExpenseUser: Record "Expense User"; + OtherExpenseUser: Record "Expense User"; + TravelRequest: Record "Spend Request"; + OriginalRequestedBy: Code[20]; + TravelRequestSystemId: Guid; + RequestBody: Text; + ResponseText: Text; + TargetURL: Text; + begin + // [SCENARIO] The legacy endpoint cannot bypass travel request ownership protection. + Initialize(); + + // [GIVEN] A travel request owned by one of two expense users. + LibraryExpense.CreateExpenseUser(ExpenseUser); + LibraryExpense.CreateExpenseUser(OtherExpenseUser); + CreateTravelRequest(TravelRequest, ExpenseUser."Employee No."); + OriginalRequestedBy := TravelRequest."Requested By"; + TravelRequestSystemId := TravelRequest.SystemId; + Commit(); + + // [WHEN] PATCH through the legacy endpoint attempts to change the owner. + TargetURL := LibraryGraphMgt.CreateTargetURL( + Format(TravelRequest.SystemId), Page::"Spend Requests API", SpendRequestsServiceNameTok); + RequestBody := StrSubstNo(RequestedByRequestBodyLbl, OtherExpenseUser."Employee No."); + asserterror LibraryGraphMgt.PatchToWebServiceAndCheckResponseCode(TargetURL, RequestBody, ResponseText, 400); + + // [THEN] The owner change is rejected and the original owner is preserved. + Assert.ExpectedError(BadRequestResponseErr); + Assert.ExpectedErrorCode('Dialog'); + AssertOwnerChangeError(ResponseText, TravelRequest); + SelectLatestVersion(); + TravelRequest.GetBySystemId(TravelRequestSystemId); + Assert.AreEqual( + OriginalRequestedBy, TravelRequest."Requested By", + 'The legacy Spend Requests API must not change the Travel Request owner.'); + end; +#endif + + local procedure VerifyTravelRequestCurrencyAPI(TargetURL: Text; Request: JsonObject; IsDetail: Boolean) + var + GeneralLedgerSetup: Record "General Ledger Setup"; + Currency: Record Currency; + TravelRequest: Record "Spend Request"; + TravelRequestDetail: Record "Spend Request Detail"; + SystemId: Guid; + ForeignCurrencyCode: Code[10]; + InvalidCurrencyCode: Code[10]; + ForeignExchangeRate: Decimal; + AmountField: Text; + RecordURL: Text; + SelectedCurrencyURL: Text; + RequestBody: Text; + ResponseText: Text; + begin + // [GIVEN] Configured LCY and a foreign currency with a non-unit exchange rate. + GeneralLedgerSetup.Get(); + GeneralLedgerSetup.TestField("LCY Code"); + // The third argument is the adjustment rate; the relational rate is always 1. + ForeignCurrencyCode := LibraryERM.CreateCurrencyWithExchangeRate(Today(), 2, 2); + Currency.Get(ForeignCurrencyCode); + ForeignExchangeRate := Currency.GetExchangeRate(Today()); + Assert.AreNotEqual(GeneralLedgerSetup."LCY Code", ForeignCurrencyCode, 'The fixture must use a foreign currency.'); + Assert.AreNotEqual(1, ForeignExchangeRate, 'The fixture must exercise exchange-rate validation.'); + InvalidCurrencyCode := CopyStr(DelChr(Format(CreateGuid()), '=', '{}-'), 1, MaxStrLen(InvalidCurrencyCode)); + Assert.AreNotEqual(GeneralLedgerSetup."LCY Code", InvalidCurrencyCode, 'The invalid code must not be LCY.'); + Assert.IsFalse(Currency.Get(InvalidCurrencyCode), 'The invalid code must not exist in Currency.'); + SystemId := CreateGuid(); + if IsDetail then + AmountField := 'expectedAmount' + else + AmountField := 'totalExpectedAmount'; + Request.Add('id', LibraryGraphMgt.StripBrackets(Format(SystemId))); + Request.Add('currencyCode', GeneralLedgerSetup."LCY Code"); + Request.Add(AmountField, 100); + Request.WriteTo(RequestBody); + Commit(); + + // [WHEN] POST explicitly supplies the LCY ISO code. + LibraryGraphMgt.PostToWebServiceAndCheckResponseCode(TargetURL, RequestBody, ResponseText, 201); + + // [THEN] It is stored as blank and returned as the configured LCY code. + AssertTravelRequestCurrency(ResponseText, SystemId, IsDetail, GeneralLedgerSetup."LCY Code", '', 1); + RecordURL := AppendPathToAPIURL(TargetURL, '(' + LibraryGraphMgt.StripBrackets(Format(SystemId)) + ')'); + if StrPos(RecordURL, '?') = 0 then + SelectedCurrencyURL := RecordURL + '?$select=currencyCode' + else + SelectedCurrencyURL := RecordURL + '&$select=currencyCode'; + LibraryGraphMgt.GetFromWebServiceAndCheckResponseCode(ResponseText, SelectedCurrencyURL, 200); + AssertTravelRequestCurrency(ResponseText, SystemId, IsDetail, GeneralLedgerSetup."LCY Code", '', 1); + + // [WHEN] PATCH switches to a configured foreign currency. + PatchCurrency(RecordURL, ForeignCurrencyCode, ResponseText); + + // [THEN] The foreign code is retained and the table computes its exchange rate. + AssertTravelRequestCurrency(ResponseText, SystemId, IsDetail, ForeignCurrencyCode, ForeignCurrencyCode, ForeignExchangeRate); + + // [WHEN] An amount-only PATCH omits currency. + Clear(Request); + Request.Add(AmountField, 200); + Request.WriteTo(RequestBody); + LibraryGraphMgt.PatchToWebServiceAndCheckResponseCode(RecordURL, RequestBody, ResponseText, 200); + + // [THEN] Neither the foreign currency nor its exchange rate is reset to LCY. + AssertTravelRequestCurrency(ResponseText, SystemId, IsDetail, ForeignCurrencyCode, ForeignCurrencyCode, ForeignExchangeRate); + + // [WHEN] PATCH supplies LCY explicitly, then switches back to foreign currency and clears it. + PatchCurrency(RecordURL, GeneralLedgerSetup."LCY Code", ResponseText); + AssertTravelRequestCurrency(ResponseText, SystemId, IsDetail, GeneralLedgerSetup."LCY Code", '', 1); + PatchCurrency(RecordURL, ForeignCurrencyCode, ResponseText); + PatchCurrency(RecordURL, '', ResponseText); + + // [THEN] Both explicit LCY and blank inputs return the canonical ISO code. + AssertTravelRequestCurrency(ResponseText, SystemId, IsDetail, GeneralLedgerSetup."LCY Code", '', 1); + LibraryGraphMgt.GetFromWebServiceAndCheckResponseCode(ResponseText, SelectedCurrencyURL, 200); + AssertTravelRequestCurrency(ResponseText, SystemId, IsDetail, GeneralLedgerSetup."LCY Code", '', 1); + + // [WHEN] PATCH supplies an unknown non-LCY code. + // [THEN] Currency validation rejects it rather than treating it as local currency. + AssertCurrencyPatchError(RecordURL, InvalidCurrencyCode, InvalidCurrencyCode); + LibraryGraphMgt.GetFromWebServiceAndCheckResponseCode(ResponseText, RecordURL, 200); + AssertTravelRequestCurrency(ResponseText, SystemId, IsDetail, GeneralLedgerSetup."LCY Code", '', 1); + + // [GIVEN] The request is no longer Open. + SelectLatestVersion(); + if IsDetail then begin + TravelRequestDetail.GetBySystemId(SystemId); + TravelRequest.Get(TravelRequestDetail."Spend Request No."); + end else + TravelRequest.GetBySystemId(SystemId); + LibraryExpense.SetSpendRequestStatus(TravelRequest, TravelRequest.Status::Released); + Commit(); + + // [WHEN] PATCH attempts a currency change. + // [THEN] The existing Open-status validation rejects it without changing currency. + AssertCurrencyPatchError(RecordURL, ForeignCurrencyCode, StatusNotOpenErr); + LibraryGraphMgt.GetFromWebServiceAndCheckResponseCode(ResponseText, RecordURL, 200); + AssertTravelRequestCurrency(ResponseText, SystemId, IsDetail, GeneralLedgerSetup."LCY Code", '', 1); + end; + + local procedure PatchCurrency(TargetURL: Text; CurrencyCode: Code[10]; var ResponseText: Text) + var + Request: JsonObject; + RequestBody: Text; + begin + Request.Add('currencyCode', CurrencyCode); + Request.WriteTo(RequestBody); + LibraryGraphMgt.PatchToWebServiceAndCheckResponseCode(TargetURL, RequestBody, ResponseText, 200); + end; + + local procedure AssertCurrencyPatchError(TargetURL: Text; CurrencyCode: Code[10]; ExpectedError: Text) + var + Request: JsonObject; + Response: JsonObject; + ErrorResponse: JsonToken; + ErrorMessage: JsonToken; + RequestBody: Text; + ResponseText: Text; + begin + Request.Add('currencyCode', CurrencyCode); + Request.WriteTo(RequestBody); + asserterror LibraryGraphMgt.PatchToWebServiceAndCheckResponseCode(TargetURL, RequestBody, ResponseText, 400); + Assert.ExpectedError(BadRequestResponseErr); + Assert.ExpectedErrorCode('Dialog'); + Response.ReadFrom(ResponseText); + Response.Get('error', ErrorResponse); + ErrorResponse.AsObject().Get('message', ErrorMessage); + Assert.AreNotEqual(0, StrPos(ErrorMessage.AsValue().AsText(), ExpectedError), 'The expected table validation must cause the rejection.'); + end; + + local procedure AssertTravelRequestCurrency(ResponseText: Text; SystemId: Guid; IsDetail: Boolean; APICurrencyCode: Code[10]; StoredCurrencyCode: Code[10]; ExchangeRate: Decimal) + var + TravelRequest: Record "Spend Request"; + TravelRequestDetail: Record "Spend Request Detail"; + Response: JsonObject; + CurrencyCode: JsonToken; + begin + Response.ReadFrom(ResponseText); + Response.Get('currencyCode', CurrencyCode); + Assert.AreEqual(APICurrencyCode, CurrencyCode.AsValue().AsText(), 'The API must expose the canonical currency code.'); + // HTTP writes run in another session; do not verify a cached record image. + SelectLatestVersion(); + if IsDetail then begin + TravelRequestDetail.GetBySystemId(SystemId); + Assert.AreEqual(StoredCurrencyCode, TravelRequestDetail."Currency Code", 'The detail must store the BC currency representation.'); + Assert.AreEqual(ExchangeRate, TravelRequestDetail."Currency Exchange Rate", 'The detail currency trigger must maintain the exchange rate.'); + end else begin + TravelRequest.GetBySystemId(SystemId); + Assert.AreEqual(StoredCurrencyCode, TravelRequest."Currency Code", 'The header must store the BC currency representation.'); + Assert.AreEqual(ExchangeRate, TravelRequest."Currency Exchange Rate", 'The header currency trigger must maintain the exchange rate.'); + end; + end; + + local procedure AppendPathToAPIURL(TargetURL: Text; PathSuffix: Text): Text + var + QueryPosition: Integer; + begin + QueryPosition := StrPos(TargetURL, '?'); + if QueryPosition = 0 then + exit(TargetURL + PathSuffix); + + exit(CopyStr(TargetURL, 1, QueryPosition - 1) + PathSuffix + CopyStr(TargetURL, QueryPosition)); + end; + + local procedure AssertTravelerGetEmployeeNumber(TravelerSystemId: Guid; ExpectedEmployeeNo: Code[20]) + var + Response: JsonObject; + TargetURL: Text; + ResponseText: Text; + begin + TargetURL := LibraryGraphMgt.CreateTargetURL(Format(TravelerSystemId), Page::"Travelers API", TravelersServiceNameTok); + LibraryGraphMgt.GetFromWebServiceAndCheckResponseCode(ResponseText, TargetURL, 200); + Response.ReadFrom(ResponseText); + AssertTravelerEmployeeNumber(Response, ExpectedEmployeeNo); + end; + + local procedure AssertTravelerEmployeeNumber(Response: JsonObject; ExpectedEmployeeNo: Code[20]) + var + EmployeeNumber: JsonToken; + begin + Assert.AreNotEqual('', ExpectedEmployeeNo, 'The fixture must have a linked Employee number.'); + Assert.IsTrue(Response.Get('employeeNumber', EmployeeNumber), 'The traveler response must contain employeeNumber.'); + Assert.AreEqual(ExpectedEmployeeNo, EmployeeNumber.AsValue().AsText(), 'The traveler must return the Employee number linked to its Expense User.'); + end; + + local procedure AssertAPIDates(ResponseText: Text; StartDate: Date; EndDate: Date) + var + Response: JsonObject; + StartDateToken: JsonToken; + EndDateToken: JsonToken; + begin + Response.ReadFrom(ResponseText); + Response.Get('expectedStartDate', StartDateToken); + Response.Get('expectedEndDate', EndDateToken); + Assert.AreEqual(StartDate, StartDateToken.AsValue().AsDate(), 'The API must return the effective start date.'); + Assert.AreEqual(EndDate, EndDateToken.AsValue().AsDate(), 'The API must return the effective end date.'); + end; + + local procedure VerifyTravelRequestDates(ResponseText: Text; RequestSystemId: Guid; StartDate: Date; EndDate: Date) + var + TravelRequest: Record "Spend Request"; + begin + AssertAPIDates(ResponseText, StartDate, EndDate); + SelectLatestVersion(); + TravelRequest.GetBySystemId(RequestSystemId); + Assert.AreEqual(StartDate, TravelRequest."Expected Start Date", 'The API start date must be persisted.'); + Assert.AreEqual(EndDate, TravelRequest."Expected End Date", 'The API end date must be persisted.'); + end; + + local procedure AssertOwnerPreservingPatch(TargetURL: Text; TravelRequest: Record "Spend Request") + var + Request: JsonObject; + Response: JsonObject; + RequestedBy: JsonToken; + Purpose: JsonToken; + RequestBody: Text; + ResponseText: Text; + ExpectedPurpose: Text; + begin + ExpectedPurpose := 'Updated business trip'; + Request.Add('requestedBy', TravelRequest."Requested By"); + Request.Add('purpose', ExpectedPurpose); + Request.WriteTo(RequestBody); + LibraryGraphMgt.PatchToWebServiceAndCheckResponseCode(TargetURL, RequestBody, ResponseText, 200); + + Response.ReadFrom(ResponseText); + Response.Get('requestedBy', RequestedBy); + Response.Get('purpose', Purpose); + Assert.AreEqual(TravelRequest."Requested By", RequestedBy.AsValue().AsText(), 'PATCH must preserve the owner.'); + Assert.AreEqual(ExpectedPurpose, Purpose.AsValue().AsText(), 'PATCH must update the purpose when the owner is unchanged.'); + end; + + local procedure AssertOwnerChangeError(ResponseText: Text; TravelRequest: Record "Spend Request") + var + Response: JsonObject; + ErrorResponse: JsonToken; + ErrorMessage: JsonToken; + MessageText: Text; + begin + Response.ReadFrom(ResponseText); + Response.Get('error', ErrorResponse); + ErrorResponse.AsObject().Get('message', ErrorMessage); + MessageText := ErrorMessage.AsValue().AsText(); + Assert.AreNotEqual(0, StrPos(MessageText, RequestedByCannotBeChangedErr), 'The API must reject the owner change.'); + Assert.AreNotEqual(0, StrPos(MessageText, TravelRequest.FieldCaption("Requested By")), 'The error must identify Requested By.'); + Assert.AreNotEqual(0, StrPos(MessageText, TravelRequest."No."), 'The error must identify the travel request.'); + end; + + local procedure CreateTravelRequest(var TravelRequest: Record "Spend Request"; EmployeeNo: Code[20]) + begin + LibraryExpense.CreateSpendRequest(TravelRequest); + TravelRequest.Validate("Requested By", EmployeeNo); + TravelRequest.Modify(true); + end; + + local procedure CreatePendingTravelRequest(var TravelRequest: Record "Spend Request"; ExpenseUser: Record "Expense User") + begin + CreateTravelRequest(TravelRequest, ExpenseUser."Employee No."); + TravelRequest.Validate("Requested For", ExpenseUser."No."); + TravelRequest.Modify(true); + LibraryExpense.SetSpendRequestStatus(TravelRequest, TravelRequest.Status::Released); + end; + + local procedure CreateApprover(var ExpenseUser: Record "Expense User") + begin + LibraryExpense.CreateExpenseUser(ExpenseUser); + ExpenseUser."Can Approve" := true; + ExpenseUser."User Id For Approvals" := CopyStr(UserId(), 1, MaxStrLen(ExpenseUser."User Id For Approvals")); + ExpenseUser.Modify(true); + end; + + local procedure Initialize() + var + ExpenseAgentSetup: Record "Expense Agent Setup"; + begin + LibraryTestInitialize.OnTestInitialize(Codeunit::"Travel Requests API Test"); + if IsInitialized then + exit; + + BindSubscription(APITestAuthHelper); + LibraryTestInitialize.OnBeforeTestSuiteInitialize(Codeunit::"Travel Requests API Test"); + if not ExpenseAgentSetup.Get() then begin + ExpenseAgentSetup.Init(); + ExpenseAgentSetup.Insert(); + end; + LibraryExpense.SetupNumberSeriesInExpenseMgmt(); + IsInitialized := true; + Commit(); + LibraryTestInitialize.OnAfterTestSuiteInitialize(Codeunit::"Travel Requests API Test"); + end; +} diff --git a/src/Apps/W1/ExpenseAgent/test/src/ExpensePermissionsTest.Codeunit.al b/src/Apps/W1/ExpenseAgent/test/src/ExpensePermissionsTest.Codeunit.al index c07de83c67d..5a906432bb9 100644 --- a/src/Apps/W1/ExpenseAgent/test/src/ExpensePermissionsTest.Codeunit.al +++ b/src/Apps/W1/ExpenseAgent/test/src/ExpensePermissionsTest.Codeunit.al @@ -5,6 +5,7 @@ namespace Microsoft.Test.ExpenseAgent; using Microsoft.ExpenseAgent; +using Microsoft.Finance.SpendRequest; using Microsoft.HumanResources.Employee; using Microsoft.HumanResources.Setup; using System.Environment.Configuration; @@ -38,6 +39,39 @@ codeunit 148338 "Expense Permissions Test" CannotDeleteEmployeeWithExpenseReportErr: Label 'You cannot delete Employee %1 because they have active expense report.', Comment = '%1 = Employee No.'; CannotDeleteEmployeeWithPostedExpenseReportErr: Label 'You cannot delete Employee %1 because they have posted expense report.', Comment = '%1 = Employee No.'; + [Test] + procedure ExpenseMgmtReadRetainsAppPermissions() + begin + // [SCENARIO] The read role retains app-owned reads without granting BaseApp request access. + VerifyExpenseMgmtPermissions('Expense Mgmt. Read', false); + end; + + [Test] + procedure ExpenseMgmtEditRetainsAppPermissions() + begin + // [SCENARIO] The edit role retains app-owned writes without granting BaseApp request access. + VerifyExpenseMgmtPermissions('Expense Mgmt. Edit', true); + end; + + [Test] + procedure ExpenseMgmtAdminRetainsAppPermissions() + begin + // [SCENARIO] The admin role retains app-owned writes without granting BaseApp request access. + VerifyExpenseMgmtPermissions('Expense Mgmt. Admin', true); + end; + + [Test] + procedure D365BasicCanUpdateTravelRequestDetailsIndirectly() + begin + VerifyTravelRequestDetailUpdateIndirectly(D365BasicPermissionSetTok); + end; + + [Test] + procedure ExpenseAgentCanUpdateTravelRequestDetailsIndirectly() + begin + VerifyTravelRequestDetailUpdateIndirectly(ExpenseAgentPermissionSetTok); + end; + [Test] procedure D365BasicCanInsertActivityIndirectly() begin @@ -50,6 +84,61 @@ codeunit 148338 "Expense Permissions Test" VerifyPermissionSetCanInsertActivity(ExpenseAgentPermissionSetTok); end; + [Test] + procedure ExpenseAgentCanApproveTravelRequestIndirectly() + var + SpendRequest: Record "Spend Request"; + ExpenseReportHeader: Record "Expense Report Header"; + ExpenseUser: Record "Expense User"; + Approver: Record "Expense User"; + TravelRequestApproval: Codeunit "Travel Request Approval"; + begin + // [SCENARIO] The agent role grants only indirect request modification through the approval codeunit. + Initialize(); + CreateTravelRequestApprovalScenario(SpendRequest, ExpenseUser, Approver); + + LibraryLowerPermissions.StartLoggingNAVPermissions(); + LibraryLowerPermissions.SetExactPermissionSet(ExpenseAgentPermissionSetTok); + Assert.IsFalse(SpendRequest.WritePermission(), 'The agent must not have direct write permission on Spend Request.'); + TravelRequestApproval.Approve(SpendRequest, Approver."No."); + RestoreFullPermissions(); + LibraryLowerPermissions.StopLoggingNAVPermissions(); + + SpendRequest.Get(SpendRequest."No."); + Assert.AreEqual(SpendRequest.Status::Approved, SpendRequest.Status, 'The authorized agent must approve the travel request.'); + ExpenseReportHeader.SetRange("Spend Request No.", SpendRequest."No."); + ExpenseReportHeader.FindFirst(); + Assert.AreEqual(ExpenseUser."No.", ExpenseReportHeader."Expense User No.", 'Approval must create the report for the requested user.'); + end; + + [Test] + procedure TravelRequestApprovalFailsWithoutExpensePermissions() + var + SpendRequest: Record "Spend Request"; + ExpenseReportHeader: Record "Expense Report Header"; + ExpenseUser: Record "Expense User"; + Approver: Record "Expense User"; + TravelRequestApproval: Codeunit "Travel Request Approval"; + begin + // [SCENARIO] An employee-only caller cannot approve requests without access to Expense User data. + Initialize(); + CreateTravelRequestApprovalScenario(SpendRequest, ExpenseUser, Approver); + Commit(); + + LibraryLowerPermissions.StartLoggingNAVPermissions(); + SetCallerPermissions(EmployeeOnlyPermissionSetTok, ExpenseUser); + asserterror TravelRequestApproval.Approve(SpendRequest, Approver."No."); + Assert.ExpectedErrorCode('DB:ClientReadDenied'); + Assert.ExpectedError(ExpenseUser.TableCaption()); + RestoreFullPermissions(); + LibraryLowerPermissions.StopLoggingNAVPermissions(); + + SpendRequest.Get(SpendRequest."No."); + Assert.AreEqual(SpendRequest.Status::Released, SpendRequest.Status, 'A denied approval must preserve the request status.'); + ExpenseReportHeader.SetRange("Spend Request No.", SpendRequest."No."); + Assert.RecordIsEmpty(ExpenseReportHeader); + end; + [Test] procedure CompanyEmailSyncsWithEmployeeOnlyPermissions() begin @@ -246,6 +335,86 @@ codeunit 148338 "Expense Permissions Test" VerifyExpenseAgentPermissionCount(AadApplication, GetCurrentCompanyName(), 0); end; + local procedure VerifyExpenseMgmtPermissions(PermissionSetId: Code[20]; CanEdit: Boolean) + var + SpendRequest: Record "Spend Request"; + SpendRequestDetail: Record "Spend Request Detail"; + SpendRequestToGLLink: Record "Spend Request To G/L Link"; + ExpenseUser: Record "Expense User"; + ExpenseReportHeader: Record "Expense Report Header"; + SpendRequestCanRead: Boolean; + SpendRequestDetailCanRead: Boolean; + SpendRequestToGLLinkCanRead: Boolean; + SpendRequestCanWrite: Boolean; + SpendRequestDetailCanWrite: Boolean; + ExpenseUserCanRead: Boolean; + ExpenseReportHeaderCanRead: Boolean; + ExpenseUserCanWrite: Boolean; + ExpenseReportHeaderCanWrite: Boolean; + begin + Initialize(); + + // [GIVEN] Only the selected Expense Management role. + LibraryLowerPermissions.StartLoggingNAVPermissions(); + LibraryLowerPermissions.SetExactPermissionSet(PermissionSetId); + + // [WHEN] The effective table permissions are evaluated. + SpendRequestCanRead := SpendRequest.ReadPermission(); + SpendRequestDetailCanRead := SpendRequestDetail.ReadPermission(); + SpendRequestToGLLinkCanRead := SpendRequestToGLLink.ReadPermission(); + SpendRequestCanWrite := SpendRequest.WritePermission(); + SpendRequestDetailCanWrite := SpendRequestDetail.WritePermission(); + ExpenseUserCanRead := ExpenseUser.ReadPermission(); + ExpenseReportHeaderCanRead := ExpenseReportHeader.ReadPermission(); + ExpenseUserCanWrite := ExpenseUser.WritePermission(); + ExpenseReportHeaderCanWrite := ExpenseReportHeader.WritePermission(); + RestoreFullPermissions(); + LibraryLowerPermissions.StopLoggingNAVPermissions(); + + // [THEN] BaseApp rights are not added to these roles; app-owned rights follow the role level. + Assert.IsFalse(SpendRequestCanRead, 'The role must not grant direct BaseApp request access.'); + Assert.IsFalse(SpendRequestDetailCanRead, 'The role must not grant direct BaseApp detail access.'); + Assert.IsFalse(SpendRequestToGLLinkCanRead, 'The role must not grant direct BaseApp ledger-link access.'); + Assert.IsFalse(SpendRequestCanWrite, 'The role must not grant direct BaseApp request writes.'); + Assert.IsFalse(SpendRequestDetailCanWrite, 'The role must not grant direct BaseApp detail writes.'); + Assert.IsTrue(ExpenseUserCanRead, 'The role must retain read access to app-owned expense users.'); + Assert.IsTrue(ExpenseReportHeaderCanRead, 'The role must retain read access to app-owned reports.'); + Assert.AreEqual(CanEdit, ExpenseUserCanWrite, 'Expense user write access must follow the role level.'); + Assert.AreEqual(CanEdit, ExpenseReportHeaderCanWrite, 'Expense report write access must follow the role level.'); + end; + + local procedure VerifyTravelRequestDetailUpdateIndirectly(PermissionSetId: Code[20]) + var + SpendRequest: Record "Spend Request"; + SpendRequestDetail: Record "Spend Request Detail"; + TravelRequestSubform: TestPage "Travel Request Subform"; + begin + // [SCENARIO] Editing a detail through its page can update both the line and its header total. + Initialize(); + LibraryExpense.CreateSpendRequest(SpendRequest); + LibraryExpense.CreateSpendRequestDetail(SpendRequestDetail, SpendRequest."No.", 10); + + // [GIVEN] The caller has indirect writes only, not direct access to change either table. + LibraryLowerPermissions.StartLoggingNAVPermissions(); + LibraryLowerPermissions.SetExactPermissionSet(PermissionSetId); + Assert.IsFalse(SpendRequest.WritePermission(), 'The caller must not have direct request write permission.'); + Assert.IsFalse(SpendRequestDetail.WritePermission(), 'The caller must not have direct detail write permission.'); + + // [WHEN] A detail amount is increased through the page with the required object permissions. + TravelRequestSubform.OpenEdit(); + TravelRequestSubform.GoToRecord(SpendRequestDetail); + TravelRequestSubform.Amount.SetValue(20); + TravelRequestSubform.Close(); + RestoreFullPermissions(); + LibraryLowerPermissions.StopLoggingNAVPermissions(); + + // [THEN] Both the line change and the base table's header update are persisted. + SpendRequestDetail.Get(SpendRequestDetail."Spend Request No.", SpendRequestDetail."Line No."); + SpendRequest.Get(SpendRequest."No."); + Assert.AreEqual(20, SpendRequestDetail."Expected Amount", 'The detail amount must be updated through indirect permissions.'); + Assert.AreEqual(20, SpendRequest."Total Expected Amount (LCY)", 'The detail update must also update the header total.'); + end; + local procedure VerifyCompanyEmailSynchronization(PermissionSetId: Code[20]) var Employee: Record Employee; @@ -273,6 +442,23 @@ codeunit 148338 "Expense Permissions Test" Assert.AreEqual(NewEmail, ExpenseUser."E-mail", 'Employee Company E-Mail must synchronize to Expense User.'); end; + local procedure CreateTravelRequestApprovalScenario(var SpendRequest: Record "Spend Request"; var ExpenseUser: Record "Expense User"; var Approver: Record "Expense User") + var + ExpenseApprovalSetup: Record "Expense Approval Setup"; + begin + LibraryExpense.CreateExpenseUser(ExpenseUser); + LibraryExpense.CreateExpenseUser(Approver); + Approver."Can Approve" := true; + Approver."User Id For Approvals" := CopyStr(UserId(), 1, MaxStrLen(Approver."User Id For Approvals")); + Approver.Modify(true); + LibraryExpense.CreateExpenseApprovalSetup(ExpenseApprovalSetup, ExpenseUser."No.", Approver."No."); + LibraryExpense.CreateSpendRequest(SpendRequest); + SpendRequest.Validate("Requested By", ExpenseUser."Employee No."); + SpendRequest.Validate("Requested For", ExpenseUser."No."); + SpendRequest.Modify(true); + LibraryExpense.SetSpendRequestStatus(SpendRequest, SpendRequest.Status::Released); + end; + local procedure CreateExpenseForDeletionGuard(var Expense: Record Expense; ExpenseUserNo: Code[20]) begin Expense.Init(); diff --git a/src/Apps/W1/ExpenseAgent/test/src/LibraryExpense.Codeunit.al b/src/Apps/W1/ExpenseAgent/test/src/LibraryExpense.Codeunit.al index ac59dcd8476..495946af537 100644 --- a/src/Apps/W1/ExpenseAgent/test/src/LibraryExpense.Codeunit.al +++ b/src/Apps/W1/ExpenseAgent/test/src/LibraryExpense.Codeunit.al @@ -315,6 +315,12 @@ codeunit 148300 "Library - Expense" internal procedure CreateSpendRequestDetail(SpendRequestNo: Code[20]; ExpectedAmount: Decimal) var SpendRequestDetail: Record "Spend Request Detail"; + begin + CreateSpendRequestDetail(SpendRequestDetail, SpendRequestNo, ExpectedAmount); + end; + + internal procedure CreateSpendRequestDetail(var SpendRequestDetail: Record "Spend Request Detail"; SpendRequestNo: Code[20]; ExpectedAmount: Decimal) + var RecordRef: RecordRef; begin SpendRequestDetail.Init(); diff --git a/src/Apps/W1/ExpenseAgent/test/src/SpendRequestTest.Codeunit.al b/src/Apps/W1/ExpenseAgent/test/src/SpendRequestTest.Codeunit.al index 358149c1de7..a7abb16f630 100644 --- a/src/Apps/W1/ExpenseAgent/test/src/SpendRequestTest.Codeunit.al +++ b/src/Apps/W1/ExpenseAgent/test/src/SpendRequestTest.Codeunit.al @@ -13,17 +13,19 @@ using Microsoft.HumanResources.Employee; codeunit 148339 "Spend Request Test" { Subtype = Test; - TestType = IntegrationTest; + TestType = UnitTest; TestPermissions = Disabled; var Assert: Codeunit "Assert"; LibraryExpense: Codeunit "Library - Expense"; + LibraryERM: Codeunit "Library - ERM"; LibraryRandom: Codeunit "Library - Random"; LibraryTestInitialize: Codeunit "Library - Test Initialize"; IsInitialized: Boolean; CloseConfirmReply: Boolean; CloseConfirmCount: Integer; + SpendReqPreviewShown: Boolean; NotTravelerErr: Label 'is not a traveler on Travel Request', Locked = true; PolicyErr: Label 'acknowledge the travel policy', Locked = true; NoTravelersErr: Label 'add at least one traveler', Locked = true; @@ -38,6 +40,20 @@ codeunit 148339 "Spend Request Test" ClosedByDocMsg: Label 'Closed By Document No. should be set on the closed spend request.'; SpendReqReleasedMsg: Label 'The spend request should be Released.'; SpendReqApprovedMsg: Label 'The spend request should be approved automatically when the agent is disabled.'; + ExpenseReportCreatedMsg: Label 'One expense report should be created for the approved travel request.'; + ExpenseReportUserMsg: Label 'The expense report should be created for the requested expense user.'; + ExpenseReportDescriptionMsg: Label 'The expense report description should match the travel request purpose.'; + TravelRequestSystemIdMsg: Label 'The expense report should reference the travel request by SystemId.'; + TravelRequestActionResultMsg: Label 'The travel request page action should return an updated result.'; + TravelRequestRejectedMsg: Label 'The travel request should be rejected through the page action.'; + TravelRequestRejectionUserMsg: Label 'The rejecting user should be recorded.'; + TravelRequestRejectionExpenseUserMsg: Label 'The rejecting expense user should be recorded.'; + TravelRequestRejectionReasonMsg: Label 'The rejection reason should be recorded.'; + TravelRequestRejectionDateMsg: Label 'The page action rejection date and time should be recorded.'; + AssignedTravelRequestVisibleMsg: Label 'The assigned approver should see the travel request.'; + UnassignedTravelRequestHiddenMsg: Label 'The approver should not see a travel request assigned to another approver.'; + DefaultTravelRequestVisibleMsg: Label 'The default approver should see travel requests without an assigned approver.'; + ApproverWithoutRequestsMsg: Label 'An approver without assigned travel requests should receive an empty result.'; SpendReqNoSetMsg: Label 'The Spend Request No. should be assigned to the expense report line.'; HeaderSpendReqNoSetMsg: Label 'The Spend Request No. should be assigned to the expense report header.'; HeaderCloseFlagMsg: Label 'The header should store the confirmed close flag.'; @@ -51,6 +67,179 @@ codeunit 148339 "Spend Request Test" CategoryClearedMsg: Label 'The expense category should be cleared when the line is not a Category line.'; MixedTypesMsg: Label 'Category and Lump Sum lines should coexist on the same travel request.'; CategoryLineOnlyErr: Label 'You can select an %1 only when %2 is %3.', Locked = true; + AutomaticApprovalNotAllowedErr: Label 'Automatic travel request approval can be used only when the Expense Agent is disabled.', Locked = true; + NotTravelRequestOwnerErr: Label 'did not create it', Locked = true; + TravelRequestMustBeApprovedErr: Label 'Travel request %1 must be approved before an expense report can be created.', Comment = '%1 = Travel Request No.', Locked = true; + ExpenseReportAlreadyLinkedErr: Label 'Expense user %1 already has expense report %2 linked to travel request %3.', Comment = '%1 = Expense User No., %2 = Expense Report No., %3 = Travel Request No.', Locked = true; + PostedReportAlreadyLinkedErr: Label 'Expense user %1 already has posted expense report %2 linked to travel request %3.', Comment = '%1 = Expense User No., %2 = Posted Expense Report No., %3 = Travel Request No.', Locked = true; + OwnerScopeRequiredErr: Label 'The create expense report action must be invoked through the owning expense user.', Locked = true; + NotTravelRequestApproverErr: Label 'is not authorized', Locked = true; + LinkedExpenseReportExistsErr: Label 'because it is linked to an expense report.', Locked = true; + InvalidTravelRequestDatesErr: Label 'Expected End Date cannot be before Expected Start Date.', Locked = true; + EmployeeNotLinkedErr: Label 'No expense user is linked to employee %1.', Comment = '%1 = Employee No.', Locked = true; + DuplicateTravelerMappingErr: Label 'is already on this travel request', Locked = true; + + [Test] + procedure EmployeeExpenseUserFilterIncludesOnlyLinkedEmployees() + var + ExpenseUser: Record "Expense User"; + Employee: Record Employee; + UnlinkedEmployee: Record Employee; + LibraryHumanResource: Codeunit "Library - Human Resource"; + begin + // [SCENARIO] The API source field filters both linked and unlinked employees without HTTP. + Initialize(); + LibraryExpense.CreateExpenseUser(ExpenseUser); + LibraryHumanResource.CreateEmployee(UnlinkedEmployee); + + Employee.SetRange("Is Expense User", true); + Employee.SetRange("No.", ExpenseUser."Employee No."); + Assert.RecordIsNotEmpty(Employee); + Employee.SetRange("No.", UnlinkedEmployee."No."); + Assert.RecordIsEmpty(Employee); + + Employee.SetRange("Is Expense User", false); + Assert.RecordIsNotEmpty(Employee); + Employee.SetRange("No.", ExpenseUser."Employee No."); + Assert.RecordIsEmpty(Employee); + end; + + [Test] + procedure TravelerEmployeeNumberMapsToExpenseUser() + var + SpendRequest: Record "Spend Request"; + ExpenseUser: Record "Expense User"; + Traveler: Record Traveler; + begin + // [SCENARIO] The same validation used by the API stores an Expense User and reads back an employee. + Initialize(); + LibraryExpense.CreateSpendRequest(SpendRequest); + LibraryExpense.CreateExpenseUser(ExpenseUser); + Assert.AreNotEqual(ExpenseUser."No.", ExpenseUser."Employee No.", 'The fixture must distinguish employee and Expense User identifiers.'); + Traveler.Validate("Spend Request No.", SpendRequest."No."); + Traveler."Line No." := 10000; + + Traveler.ValidateEmployeeNo(ExpenseUser."Employee No."); + Traveler.Insert(true); + + Traveler.Get(SpendRequest."No.", 10000); + Traveler.TestField("Expense User No.", ExpenseUser."No."); + Traveler.CalcFields("Employee No."); + Traveler.TestField("Employee No.", ExpenseUser."Employee No."); + end; + + [Test] + procedure TravelerEmployeeNumberRejectsUnlinkedEmployee() + var + SpendRequest: Record "Spend Request"; + Employee: Record Employee; + Traveler: Record Traveler; + LibraryHumanResource: Codeunit "Library - Human Resource"; + begin + // [SCENARIO] An employee without an Expense User cannot be added as a traveler. + Initialize(); + LibraryExpense.CreateSpendRequest(SpendRequest); + LibraryHumanResource.CreateEmployee(Employee); + Traveler.Validate("Spend Request No.", SpendRequest."No."); + Traveler."Line No." := 10000; + + asserterror Traveler.ValidateEmployeeNo(Employee."No."); + + Assert.ExpectedError(StrSubstNo(EmployeeNotLinkedErr, Employee."No.")); + Traveler.SetRange("Spend Request No.", SpendRequest."No."); + Assert.RecordIsEmpty(Traveler); + end; + + [Test] + procedure TravelerEmployeeNumberRejectsBlank() + var + SpendRequest: Record "Spend Request"; + UnlinkedExpenseUser: Record "Expense User"; + Traveler: Record Traveler; + begin + // [SCENARIO] A blank employee number must not resolve to an unlinked Expense User. + Initialize(); + LibraryExpense.CreateSpendRequest(SpendRequest); + LibraryExpense.CreateExpenseUser(UnlinkedExpenseUser); + UnlinkedExpenseUser.Validate("Employee No.", ''); + UnlinkedExpenseUser.Modify(true); + Traveler.Validate("Spend Request No.", SpendRequest."No."); + Traveler."Line No." := 10000; + + asserterror Traveler.ValidateEmployeeNo(''); + + Assert.ExpectedError(StrSubstNo(EmployeeNotLinkedErr, '')); + Traveler.SetRange("Spend Request No.", SpendRequest."No."); + Assert.RecordIsEmpty(Traveler); + end; + + [Test] + procedure TravelerEmployeeNumberPreservesValidationRules() + var + SpendRequest: Record "Spend Request"; + ExpenseUser: Record "Expense User"; + Traveler: Record Traveler; + begin + // [SCENARIO] Employee-based writes retain the duplicate-traveler and open-status guards. + Initialize(); + CreateReleasableSpendRequest(SpendRequest, ExpenseUser); + Traveler.Validate("Spend Request No.", SpendRequest."No."); + Traveler."Line No." := 20000; + Commit(); + + asserterror Traveler.ValidateEmployeeNo(ExpenseUser."Employee No."); + Assert.ExpectedError(DuplicateTravelerMappingErr); + + LibraryExpense.SetSpendRequestStatus(SpendRequest, SpendRequest.Status::Approved); + Commit(); + asserterror Traveler.ValidateEmployeeNo(ExpenseUser."Employee No."); + Assert.ExpectedError(StatusNotOpenErr); + + Traveler.SetRange("Spend Request No.", SpendRequest."No."); + Assert.RecordCount(Traveler, 1); + end; + + [Test] + procedure TravelRequestEmployeeQueryReturnsOnlyItsTravelers() + var + SpendRequest: Record "Spend Request"; + OtherSpendRequest: Record "Spend Request"; + EmptySpendRequest: Record "Spend Request"; + ExpenseUser: Record "Expense User"; + OtherExpenseUser: Record "Expense User"; + AdditionalExpenseUser: Record "Expense User"; + TravelRequestEmployees: Query "Travel Request Employees"; + ExpectedEmployees: List of [Code[20]]; + begin + // [SCENARIO] Employee navigation uses the request SystemId and excludes other requests' travelers. + Initialize(); + CreateReleasableSpendRequest(SpendRequest, ExpenseUser); + CreateReleasableSpendRequest(OtherSpendRequest, OtherExpenseUser); + LibraryExpense.CreateExpenseUser(AdditionalExpenseUser); + LibraryExpense.CreateTraveler(SpendRequest."No.", AdditionalExpenseUser."No."); + LibraryExpense.CreateSpendRequest(EmptySpendRequest); + ExpectedEmployees.Add(ExpenseUser."Employee No."); + ExpectedEmployees.Add(AdditionalExpenseUser."Employee No."); + + TravelRequestEmployees.SetRange(travelRequestSystemId, SpendRequest.SystemId); + TravelRequestEmployees.Open(); + while TravelRequestEmployees.Read() do + Assert.IsTrue(ExpectedEmployees.Remove(TravelRequestEmployees.employeeNo), 'Navigation must return each expected employee once and no unrelated employees.'); + TravelRequestEmployees.Close(); + Assert.AreEqual(0, ExpectedEmployees.Count(), 'Both travelers must be included in employee navigation.'); + + TravelRequestEmployees.SetRange(travelRequestSystemId, OtherSpendRequest.SystemId); + TravelRequestEmployees.Open(); + Assert.IsTrue(TravelRequestEmployees.Read(), 'The other request must return its traveler.'); + Assert.AreEqual(OtherExpenseUser."Employee No.", TravelRequestEmployees.employeeNo, 'Navigation must use the selected request SystemId.'); + Assert.IsFalse(TravelRequestEmployees.Read(), 'The other request must not return the first request''s travelers.'); + TravelRequestEmployees.Close(); + + TravelRequestEmployees.SetRange(travelRequestSystemId, EmptySpendRequest.SystemId); + TravelRequestEmployees.Open(); + Assert.IsFalse(TravelRequestEmployees.Read(), 'A request without travelers must not return all employees.'); + TravelRequestEmployees.Close(); + end; [Test] [HandlerFunctions('SpendReqConfirmHandler')] @@ -118,252 +307,1290 @@ codeunit 148339 "Spend Request Test" LibraryExpense.CreateSpendRequestDetail(SpendRequest."No.", LibraryRandom.RandIntInRange(100000, 100000)); LibraryExpense.SetSpendRequestStatus(SpendRequest, SpendRequest.Status::Approved); - // [WHEN] The spend request is selected on the line. - asserterror ExpenseReportLine.Validate("Spend Request No.", SpendRequest."No."); + // [WHEN] The spend request is selected on the line. + asserterror ExpenseReportLine.Validate("Spend Request No.", SpendRequest."No."); + + // [THEN] Validation fails because the user is not a traveler. + Assert.ExpectedError(NotTravelerErr); + end; + + [Test] + procedure ValidateSpendReqNoFailsWhenNotApproved() + var + SpendRequest: Record "Spend Request"; + ExpenseUser: Record "Expense User"; + ExpenseReportLine: Record "Expense Report Line"; + begin + // [SCENARIO 616928] A spend request that is not Approved cannot be selected on a line. + Initialize(); + + // [GIVEN] A refundable expense report line for an expense user. + CreateExpenseReportWithRefundableLine(ExpenseReportLine, ExpenseUser, true); + + // [GIVEN] A Release (not approved) spend request with the user as a traveler. + CreateSpendRequestWithTraveler(SpendRequest, ExpenseUser."No.", SpendRequest.Status::Released); + + // [WHEN] The spend request is selected on the line. + asserterror ExpenseReportLine.Validate("Spend Request No.", SpendRequest."No."); + + // [THEN] Validation fails because only approved spend requests are selectable. + Assert.ExpectedErrorCode('DB:NothingInsideFilter'); + end; + + [Test] + [HandlerFunctions('SpendReqConfirmHandler')] + procedure SetRefundableFalseClearsLinkedSpendReq() + var + SpendRequest: Record "Spend Request"; + ExpenseUser: Record "Expense User"; + ExpenseReportLine: Record "Expense Report Line"; + begin + // [SCENARIO 616928] Making a line non-refundable clears its linked spend request. + Initialize(); + + // [GIVEN] A refundable expense report line for an expense user. + CreateExpenseReportWithRefundableLine(ExpenseReportLine, ExpenseUser, true); + + // [GIVEN] An approved spend request with the user as a traveler. + CreateSpendRequestWithTraveler(SpendRequest, ExpenseUser."No.", SpendRequest.Status::Approved); + + // [GIVEN] The spend request is linked to the line. + ExpenseReportLine.Validate("Spend Request No.", SpendRequest."No."); + ExpenseReportLine.Modify(true); + + // [WHEN] The line is set to non-refundable. + ExpenseReportLine.Validate(Refundable, false); + + // [THEN] The spend request link is cleared from the line. + Assert.AreEqual('', ExpenseReportLine."Spend Request No.", SpendReqClearedMsg); + Assert.IsFalse(ExpenseReportLine."Spend Request Close", SpendReqCloseClearedMsg); + end; + + [Test] + procedure ReleaseSpendReqFailsMissingRequestedFor() + var + SpendRequest: Record "Spend Request"; + ExpenseUser: Record "Expense User"; + ReleaseSpendRequest: Codeunit "Release Spend Request"; + begin + // [SCENARIO 616928] Releasing an expense spend request without "Requested For" fails. + Initialize(); + + // [GIVEN] An expense user. + LibraryExpense.CreateExpenseUser(ExpenseUser); + + // [GIVEN] An open spend request with valid dates and the travel policy acknowledged, but no Requested For. + LibraryExpense.CreateSpendRequest(SpendRequest); + SpendRequest.Validate("Expected Start Date", WorkDate()); + SpendRequest.Validate("Expected End Date", WorkDate() + 7); + SpendRequest.Validate("Travel Policy Acknowledgment", true); + SpendRequest.Modify(true); + + // [WHEN] The spend request is Released. + asserterror ReleaseSpendRequest.Release(SpendRequest); + + // [THEN] Release fails because Requested For is required. + Assert.ExpectedError(FieldRequiredErr); + end; + + [Test] + procedure ReleaseSpendReqFailsMissingExpectedStartDate() + var + SpendRequest: Record "Spend Request"; + ExpenseUser: Record "Expense User"; + ReleaseSpendRequest: Codeunit "Release Spend Request"; + begin + // [SCENARIO 616928] Releasing an expense spend request without "Expected Start Date" fails. + Initialize(); + + // [GIVEN] A releasable spend request. + CreateReleasableSpendRequest(SpendRequest, ExpenseUser); + + // [GIVEN] Its expected start date is cleared. + SpendRequest."Expected Start Date" := 0D; + SpendRequest.Modify(); + + // [WHEN] The spend request is Released. + asserterror ReleaseSpendRequest.Release(SpendRequest); + + // [THEN] Release fails because Expected Start Date is required. + Assert.ExpectedError(FieldRequiredErr); + end; + + [Test] + procedure ReleaseSpendReqFailsMissingExpectedEndDate() + var + SpendRequest: Record "Spend Request"; + ExpenseUser: Record "Expense User"; + ReleaseSpendRequest: Codeunit "Release Spend Request"; + begin + // [SCENARIO 616928] Releasing an expense spend request without "Expected End Date" fails. + Initialize(); + + // [GIVEN] A releasable spend request. + CreateReleasableSpendRequest(SpendRequest, ExpenseUser); + + // [GIVEN] Its expected end date is cleared. + SpendRequest."Expected End Date" := 0D; + SpendRequest.Modify(); + + // [WHEN] The spend request is Released. + asserterror ReleaseSpendRequest.Release(SpendRequest); + + // [THEN] Release fails because Expected End Date is required. + Assert.ExpectedError(FieldRequiredErr); + end; + + [Test] + procedure ReleaseSpendReqFailsPolicyNotAcknowledged() + var + SpendRequest: Record "Spend Request"; + ExpenseUser: Record "Expense User"; + ReleaseSpendRequest: Codeunit "Release Spend Request"; + begin + // [SCENARIO 616928] Releasing an expense spend request without acknowledging the travel policy fails. + Initialize(); + + // [GIVEN] A releasable spend request. + CreateReleasableSpendRequest(SpendRequest, ExpenseUser); + + // [GIVEN] The travel policy acknowledgment is cleared. + SpendRequest.Validate("Travel Policy Acknowledgment", false); + SpendRequest.Modify(true); + + // [WHEN] The spend request is Released. + asserterror ReleaseSpendRequest.Release(SpendRequest); + + // [THEN] Release fails because the travel policy must be acknowledged. + Assert.ExpectedError(PolicyErr); + end; + + [Test] + procedure ReleaseSpendReqFailsIntlNoDestination() + var + SpendRequest: Record "Spend Request"; + ExpenseUser: Record "Expense User"; + ReleaseSpendRequest: Codeunit "Release Spend Request"; + begin + // [SCENARIO 616928] Releasing an international expense spend request without a destination country fails. + Initialize(); + + // [GIVEN] A releasable spend request. + CreateReleasableSpendRequest(SpendRequest, ExpenseUser); + + // [GIVEN] It is flagged as international travel with no destination country. + SpendRequest.Validate("International Travel", true); + SpendRequest.Modify(true); + + // [WHEN] The spend request is Released. + asserterror ReleaseSpendRequest.Release(SpendRequest); + + // [THEN] Release fails because a destination country is required for international travel. + Assert.ExpectedError(DestinationErr); + end; + + [Test] + procedure ReleaseSpendReqFailsNoTravelers() + var + SpendRequest: Record "Spend Request"; + ExpenseUser: Record "Expense User"; + ReleaseSpendRequest: Codeunit "Release Spend Request"; + begin + // [SCENARIO 616928] Releasing an expense spend request without any travelers fails. + Initialize(); + + // [GIVEN] A releasable spend request. + CreateReleasableSpendRequest(SpendRequest, ExpenseUser); + + // [GIVEN] Its travelers are removed. + DeleteTravelers(SpendRequest."No."); + + // [WHEN] The spend request is Released. + asserterror ReleaseSpendRequest.Release(SpendRequest); + + // [THEN] Release fails because at least one traveler is required. + Assert.ExpectedError(NoTravelersErr); + end; + + [Test] + procedure ReleaseSpendReqAutoApprovesWhenAgentDisabled() + var + ExpenseReportHeader: Record "Expense Report Header"; + SpendRequest: Record "Spend Request"; + ExpenseUser: Record "Expense User"; + ReleaseSpendRequest: Codeunit "Release Spend Request"; + begin + // [SCENARIO 616928] With the agent disabled, releasing an expense spend request that meets all prerequisites approves it automatically. + Initialize(); + + // [GIVEN] A releasable spend request with every prerequisite satisfied. + CreateReleasableSpendRequest(SpendRequest, ExpenseUser); + + // [WHEN] The spend request is Released. + ReleaseSpendRequest.Release(SpendRequest); + + // [THEN] The spend request is approved automatically because there is no agent to approve it. + SpendRequest.Get(SpendRequest."No."); + Assert.AreEqual(SpendRequest.Status::Approved, SpendRequest.Status, SpendReqApprovedMsg); + + ExpenseReportHeader.SetRange("Spend Request No.", SpendRequest."No."); + Assert.AreEqual(1, ExpenseReportHeader.Count(), ExpenseReportCreatedMsg); + ExpenseReportHeader.FindFirst(); + Assert.AreEqual(ExpenseUser."No.", ExpenseReportHeader."Expense User No.", ExpenseReportUserMsg); + end; + + [Test] + procedure ReleaseSpendReqStaysReleasedWhenAgentEnabled() + var + ExpenseReportHeader: Record "Expense Report Header"; + SpendRequest: Record "Spend Request"; + ExpenseUser: Record "Expense User"; + ReleaseSpendRequest: Codeunit "Release Spend Request"; + begin + // [SCENARIO 616928] With the agent enabled, releasing an expense spend request leaves it Released for the agent to approve. + Initialize(); + LibraryExpense.UpdateEnableAgentInAgentSetup(true); + + // [GIVEN] A releasable spend request with every prerequisite satisfied. + CreateReleasableSpendRequest(SpendRequest, ExpenseUser); + + // [WHEN] The spend request is Released. + ReleaseSpendRequest.Release(SpendRequest); + + // [THEN] The spend request stays Released. + SpendRequest.Get(SpendRequest."No."); + Assert.AreEqual(SpendRequest.Status::Released, SpendRequest.Status, SpendReqReleasedMsg); + ExpenseReportHeader.SetRange("Spend Request No.", SpendRequest."No."); + Assert.RecordIsEmpty(ExpenseReportHeader); + end; + + [Test] + procedure DeleteTravelRequestWithReportIsBlocked() + var + SpendRequest: Record "Spend Request"; + SpendRequestDetail: Record "Spend Request Detail"; + ExpenseUser: Record "Expense User"; + ExpenseReportHeader: Record "Expense Report Header"; + Traveler: Record Traveler; + ReleaseSpendRequest: Codeunit "Release Spend Request"; + begin + // [SCENARIO] A travel request cannot be deleted while an expense report references it. + Initialize(); + + // [GIVEN] An automatically approved travel request with a detail, traveler, and linked report. + CreateReleasableSpendRequest(SpendRequest, ExpenseUser); + LibraryExpense.CreateSpendRequestDetail(SpendRequestDetail, SpendRequest."No.", 0); + ReleaseSpendRequest.Release(SpendRequest); + ExpenseReportHeader.SetRange("Spend Request No.", SpendRequest."No."); + ExpenseReportHeader.FindFirst(); + Commit(); + + // [WHEN] The request is deleted. + asserterror SpendRequest.Delete(true); + + // [THEN] The request, report, details, and travelers remain intact. + Assert.ExpectedError(LinkedExpenseReportExistsErr); + Assert.ExpectedError(SpendRequest."No."); + Assert.IsTrue(SpendRequest.Get(SpendRequest."No."), 'The linked travel request must not be deleted.'); + Assert.RecordIsNotEmpty(ExpenseReportHeader); + Assert.IsTrue(SpendRequestDetail.Get(SpendRequest."No.", SpendRequestDetail."Line No."), 'The request detail must remain.'); + Traveler.SetRange("Spend Request No.", SpendRequest."No."); + Assert.RecordIsNotEmpty(Traveler); + end; + + [Test] + [HandlerFunctions('SpendReqConfirmHandler')] + procedure DeleteTravelRequestWithPostedReportIsBlocked() + var + SpendRequest: Record "Spend Request"; + PostedExpenseReportHeader: Record "Posted Expense Report Header"; + begin + // [SCENARIO] Posted report references prevent deletion even when the net spent amount is zero. + Initialize(); + + // [GIVEN] A normally posted zero-amount report with a header-level request link. + CreatePostedTravelRequestReport(SpendRequest, PostedExpenseReportHeader, true); + PostedExpenseReportHeader.TestField("Spend Request No.", SpendRequest."No."); + Commit(); + + // [WHEN] The request is deleted. + asserterror SpendRequest.Delete(true); + + // [THEN] Both the request and posted history remain intact. + Assert.ExpectedError(LinkedExpenseReportExistsErr); + Assert.IsTrue(SpendRequest.Get(SpendRequest."No."), 'A request referenced by posted history must remain.'); + Assert.IsTrue(PostedExpenseReportHeader.Get(PostedExpenseReportHeader."No."), 'Posted history must never be cascade-deleted.'); + end; + + [Test] + [HandlerFunctions('SpendReqConfirmHandler')] + procedure DeleteTravelRequestWithPostedLineIsBlocked() + var + SpendRequest: Record "Spend Request"; + PostedExpenseReportHeader: Record "Posted Expense Report Header"; + PostedExpenseReportLine: Record "Posted Expense Report Line"; + begin + // [SCENARIO] A posted line can link a request independently of its report header. + Initialize(); + + // [GIVEN] Normal posting produces line-only references and zero net spend. + CreatePostedTravelRequestReport(SpendRequest, PostedExpenseReportHeader, false); + PostedExpenseReportHeader.TestField("Spend Request No.", ''); + PostedExpenseReportLine.SetRange("Document No.", PostedExpenseReportHeader."No."); + PostedExpenseReportLine.SetRange("Spend Request No.", SpendRequest."No."); + PostedExpenseReportLine.FindFirst(); + Commit(); + + // [WHEN] The request is deleted. + asserterror SpendRequest.Delete(true); + + // [THEN] Line-only references are protected without removing posted records. + Assert.ExpectedError(LinkedExpenseReportExistsErr); + Assert.IsTrue(SpendRequest.Get(SpendRequest."No."), 'The line-linked request must remain.'); + Assert.IsTrue(PostedExpenseReportLine.Get(PostedExpenseReportHeader."No.", PostedExpenseReportLine."Line No."), 'The posted line must remain.'); + Assert.IsTrue(PostedExpenseReportHeader.Get(PostedExpenseReportHeader."No."), 'The posted header must remain.'); + end; + + [Test] + [HandlerFunctions('SpendReqConfirmHandler')] + procedure DeleteTravelRequestWithUnpostedLineIsBlocked() + var + SpendRequest: Record "Spend Request"; + ExpenseReportHeader: Record "Expense Report Header"; + ExpenseReportLine: Record "Expense Report Line"; + begin + // [SCENARIO] An unposted line's independent travel-request reference prevents deletion. + Initialize(); + + // [GIVEN] Only an expense report line references the travel request. + CreateAndPostExpenseReportWithSpendRequest(ExpenseReportHeader, SpendRequest, 1); + ExpenseReportHeader.TestField("Spend Request No.", ''); + ExpenseReportLine.SetRange("Document No.", ExpenseReportHeader."No."); + ExpenseReportLine.SetRange("Spend Request No.", SpendRequest."No."); + ExpenseReportLine.FindFirst(); + Commit(); + + // [WHEN] The request is deleted. + asserterror SpendRequest.Delete(true); + + // [THEN] The request and the referencing line remain intact. + Assert.ExpectedError(LinkedExpenseReportExistsErr); + Assert.IsTrue(SpendRequest.Get(SpendRequest."No."), 'The line-linked request must remain.'); + Assert.IsTrue(ExpenseReportLine.Get(ExpenseReportHeader."No.", ExpenseReportLine."Line No."), 'The report line must remain.'); + end; + + [Test] + procedure DeleteTravelRequestWithoutReportRemovesDependents() + var + SpendRequest: Record "Spend Request"; + SpendRequestDetail: Record "Spend Request Detail"; + ExpenseUser: Record "Expense User"; + Traveler: Record Traveler; + TravelRequestNo: Code[20]; + begin + // [SCENARIO] A travel request without a linked report can still be deleted with its dependents. + Initialize(); + + // [GIVEN] An open travel request with a detail and an automatically created traveler. + CreateReleasableSpendRequest(SpendRequest, ExpenseUser); + LibraryExpense.CreateSpendRequestDetail(SpendRequestDetail, SpendRequest."No.", 0); + TravelRequestNo := SpendRequest."No."; + + // [WHEN] The request is deleted. + SpendRequest.Delete(true); + + // [THEN] The request and its dependent details and travelers are removed. + Assert.IsFalse(SpendRequest.Get(TravelRequestNo), 'The unlinked travel request must be deleted.'); + SpendRequestDetail.SetRange("Spend Request No.", TravelRequestNo); + Assert.RecordIsEmpty(SpendRequestDetail); + Traveler.SetRange("Spend Request No.", TravelRequestNo); + Assert.RecordIsEmpty(Traveler); + end; + + [Test] + procedure TravelRequestInsertPreservesAPIDates() + var + SpendRequest: Record "Spend Request"; + begin + // [SCENARIO] Explicit API dates survive insertion; only omitted dates receive defaults. + Initialize(); + + // [GIVEN] A request supplying a future date pair. + PrepareTravelRequestWithAPIDates(SpendRequest, WorkDate() + 30, WorkDate() + 33, true, true); + + // [WHEN] The table's insert triggers run. + SpendRequest.Insert(true); + + // [THEN] The supplied dates are persisted. + SpendRequest.Get(SpendRequest."No."); + AssertTravelRequestDates(SpendRequest, WorkDate() + 30, WorkDate() + 33); + + // [WHEN] The same record variable inserts again without any date inputs. + SpendRequest.Init(); + SpendRequest."No." := ''; + SpendRequest."Document Type" := SpendRequest."Document Type"::"Travel Request"; + SpendRequest.Insert(true); + + // [THEN] The previous override was consumed and normal defaults apply. + AssertTravelRequestDates(SpendRequest, WorkDate(), WorkDate()); + + // [WHEN] Only an end date is supplied on another insertion. + PrepareTravelRequestWithAPIDates(SpendRequest, 0D, WorkDate() + 7, false, true); + SpendRequest.Insert(true); + + // [THEN] The start defaults and the supplied end is preserved. + AssertTravelRequestDates(SpendRequest, WorkDate(), WorkDate() + 7); + + // [WHEN] Only a start date is supplied on another insertion. + PrepareTravelRequestWithAPIDates(SpendRequest, WorkDate() - 7, 0D, true, false); + SpendRequest.Insert(true); + + // [THEN] The end defaults and the supplied start is preserved. + AssertTravelRequestDates(SpendRequest, WorkDate() - 7, WorkDate()); + end; + + [Test] + procedure TravelRequestDatePairMovesLaterAndEarlier() + var + SpendRequest: Record "Spend Request"; + begin + // [SCENARIO] Complete date ranges can move past the old end or before the old start. + Initialize(); + + // [GIVEN] An open request with its default date pair. + LibraryExpense.CreateSpendRequest(SpendRequest); + + // [WHEN] Both dates move past the old end. + SpendRequest.ApplyExpectedDatesFromAPI(WorkDate() + 30, WorkDate() + 33, true, true); + SpendRequest.Modify(true); + + // [THEN] The complete later pair is accepted. + SpendRequest.Get(SpendRequest."No."); + AssertTravelRequestDates(SpendRequest, WorkDate() + 30, WorkDate() + 33); + + // [WHEN] Both dates move before the old start. + SpendRequest.ApplyExpectedDatesFromAPI(WorkDate() - 33, WorkDate() - 30, true, true); + SpendRequest.Modify(true); + + // [THEN] The complete earlier pair is accepted. + SpendRequest.Get(SpendRequest."No."); + AssertTravelRequestDates(SpendRequest, WorkDate() - 33, WorkDate() - 30); + end; + + [Test] + procedure TravelRequestDateChangesPreserveOmittedFields() + var + SpendRequest: Record "Spend Request"; + begin + // [SCENARIO] Partial date updates use stored values for omitted fields. + Initialize(); + + // [GIVEN] An open request with its default date pair. + LibraryExpense.CreateSpendRequest(SpendRequest); + + // [WHEN] Only the end date is changed. + SpendRequest.ApplyExpectedDatesFromAPI(0D, WorkDate() + 20, false, true); + SpendRequest.Modify(true); + + // [THEN] The start remains unchanged. + AssertTravelRequestDates(SpendRequest, WorkDate(), WorkDate() + 20); + + // [WHEN] Only the start is changed, followed by a request omitting both dates. + SpendRequest.ApplyExpectedDatesFromAPI(WorkDate() + 10, 0D, true, false); + SpendRequest.ApplyExpectedDatesFromAPI(0D, 0D, false, false); + SpendRequest.Modify(true); + + // [THEN] The effective date pair is preserved. + SpendRequest.Get(SpendRequest."No."); + AssertTravelRequestDates(SpendRequest, WorkDate() + 10, WorkDate() + 20); + end; + + [Test] + procedure TravelRequestAPIDatesKeepValidation() + var + SpendRequest: Record "Spend Request"; + InvalidRequest: Record "Spend Request"; + begin + // [SCENARIO] Deferred validation still rejects invalid ranges and edits to released requests. + Initialize(); + + // [GIVEN] An open request with its default date pair. + LibraryExpense.CreateSpendRequest(SpendRequest); + Commit(); + + // [WHEN] A start-only update exceeds the stored end. + asserterror SpendRequest.ApplyExpectedDatesFromAPI(WorkDate() + 1, 0D, true, false); + + // [THEN] The range is rejected and stored dates are unchanged. + Assert.ExpectedError(InvalidTravelRequestDatesErr); + SpendRequest.Get(SpendRequest."No."); + AssertTravelRequestDates(SpendRequest, WorkDate(), WorkDate()); + + // [WHEN] An invalid complete pair is supplied. + asserterror SpendRequest.ApplyExpectedDatesFromAPI(WorkDate() + 10, WorkDate() + 5, true, true); + + // [THEN] The final invalid pair is rejected. + Assert.ExpectedError(InvalidTravelRequestDatesErr); + SpendRequest.Get(SpendRequest."No."); + + // [WHEN] A new request supplies an invalid pair. + PrepareTravelRequestWithAPIDates(InvalidRequest, WorkDate() + 10, WorkDate() + 5, true, true); + asserterror InvalidRequest.Insert(true); + + // [THEN] Insertion fails rather than replacing the inputs with valid defaults. + Assert.ExpectedError(InvalidTravelRequestDatesErr); + + // [WHEN] A released request receives a valid new pair. + LibraryExpense.SetSpendRequestStatus(SpendRequest, SpendRequest.Status::Released); + Commit(); + asserterror SpendRequest.ApplyExpectedDatesFromAPI(WorkDate() + 30, WorkDate() + 33, true, true); + + // [THEN] The existing status guard still rejects the edit. + Assert.ExpectedError(StatusNotOpenErr); + end; + + [Test] + procedure AutomaticTravelRequestApprovalRequiresDisabledAgent() + var + SpendRequest: Record "Spend Request"; + ExpenseUser: Record "Expense User"; + TravelRequestApproval: Codeunit "Travel Request Approval"; + begin + // [SCENARIO] Automatic approval is rejected while the Expense Agent is enabled. + Initialize(); + + // [GIVEN] A released travel request with the Expense Agent enabled. + LibraryExpense.UpdateEnableAgentInAgentSetup(true); + CreateReleasableSpendRequest(SpendRequest, ExpenseUser); + LibraryExpense.SetSpendRequestStatus(SpendRequest, SpendRequest.Status::Released); + + // [WHEN] Automatic approval is attempted. + asserterror TravelRequestApproval.ApproveAutomatically(SpendRequest); + + // [THEN] Approval fails because the agent must be disabled. + Assert.ExpectedError(AutomaticApprovalNotAllowedErr); + end; + + [Test] + procedure ApproveTravelRequestCreatesExpenseReport() + var + ExpenseReportHeader: Record "Expense Report Header"; + SpendRequest: Record "Spend Request"; + ExpenseUser: Record "Expense User"; + ApproverExpenseUser: Record "Expense User"; + ReleaseSpendRequest: Codeunit "Release Spend Request"; + TravelRequestApproval: Codeunit "Travel Request Approval"; + ExpectedDescription: Text[100]; + TravelRequestPurpose: Text[150]; + begin + // [SCENARIO] Approving a travel request creates a linked report for its requested user. + Initialize(); + + // [GIVEN] A released travel request with a long purpose and an assigned approver. + LibraryExpense.UpdateEnableAgentInAgentSetup(true); + + CreateReleasableSpendRequest(SpendRequest, ExpenseUser); + TravelRequestPurpose := PadStr('Customer conference ', MaxStrLen(TravelRequestPurpose), 'x'); + ExpectedDescription := CopyStr(TravelRequestPurpose, 1, MaxStrLen(ExpectedDescription)); + SpendRequest.Validate(Purpose, TravelRequestPurpose); + SpendRequest.Modify(true); + ReleaseSpendRequest.Release(SpendRequest); + CreateApproverForExpenseUser(ApproverExpenseUser, ExpenseUser); + + // [WHEN] The assigned approver approves the request. + TravelRequestApproval.Approve(SpendRequest, ApproverExpenseUser."No."); + + // [THEN] One linked report is created with the requested user and truncated purpose. + ExpenseReportHeader.SetRange("Spend Request No.", SpendRequest."No."); + Assert.AreEqual(1, ExpenseReportHeader.Count(), ExpenseReportCreatedMsg); + ExpenseReportHeader.FindFirst(); + Assert.AreEqual(ExpenseUser."No.", ExpenseReportHeader."Expense User No.", ExpenseReportUserMsg); + Assert.AreEqual(ExpectedDescription, ExpenseReportHeader.Description, ExpenseReportDescriptionMsg); + ExpenseReportHeader.CalcFields("Travel Request SystemId"); + Assert.AreEqual(SpendRequest.SystemId, ExpenseReportHeader."Travel Request SystemId", TravelRequestSystemIdMsg); + end; + + [Test] + [HandlerFunctions('SpendReqConfirmHandler')] + procedure ReapproveTravelRequestWithPostedHeader() + var + SpendRequest: Record "Spend Request"; + ExpenseUser: Record "Expense User"; + ApproverExpenseUser: Record "Expense User"; + PostedExpenseReportHeader: Record "Posted Expense Report Header"; + ReleaseSpendRequest: Codeunit "Release Spend Request"; + TravelRequestApproval: Codeunit "Travel Request Approval"; + begin + // [FEATURE] [AI test 1.0] + // [SCENARIO 626966] Reapproving a reopened request with posted header history does not create another report. + Initialize(); + + // [GIVEN] Request "R" has posted header history for user "U", zero net spend, and no draft. + CreatePostedTravelRequestReport(SpendRequest, PostedExpenseReportHeader, true); + LibraryExpense.UpdateEnableAgentInAgentSetup(true); + ExpenseUser.Get(SpendRequest."Requested For"); + CreateApproverForExpenseUser(ApproverExpenseUser, ExpenseUser); + Assert.AreEqual(SpendRequest."No.", PostedExpenseReportHeader."Spend Request No.", 'The posted header must link the request.'); + VerifyPostedTravelRequestHistory(SpendRequest, PostedExpenseReportHeader); + + // [WHEN] Owner "U" reopens and resubmits "R". + ReleaseSpendRequest.Reopen(SpendRequest); + SpendRequest.Get(SpendRequest."No."); + Assert.AreEqual(SpendRequest.Status::Open, SpendRequest.Status, 'The request must persist as open after reopening.'); + TravelRequestApproval.Submit(SpendRequest, ExpenseUser."No."); + + // [THEN] Request "R" awaits its assigned approver rather than being approved automatically. + SpendRequest.Get(SpendRequest."No."); + Assert.AreEqual(SpendRequest.Status::Released, SpendRequest.Status, SpendReqReleasedMsg); + + // [WHEN] Assigned approver "A" approves "R". + TravelRequestApproval.Approve(SpendRequest, ApproverExpenseUser."No."); + + // [THEN] Approval is persisted without creating a draft or changing posted history. + VerifyReapprovedTravelRequest(SpendRequest, PostedExpenseReportHeader, ExpenseUser."No.", ApproverExpenseUser."No."); + end; + + [Test] + [HandlerFunctions('SpendReqConfirmHandler')] + procedure ReapproveTravelRequestWithPostedLine() + var + SpendRequest: Record "Spend Request"; + ExpenseUser: Record "Expense User"; + ApproverExpenseUser: Record "Expense User"; + PostedExpenseReportHeader: Record "Posted Expense Report Header"; + ReleaseSpendRequest: Codeunit "Release Spend Request"; + TravelRequestApproval: Codeunit "Travel Request Approval"; + begin + // [FEATURE] [AI test 1.0] + // [SCENARIO 626966] Reapproving a reopened request with line-only posted history does not create another report. + Initialize(); + + // [GIVEN] Only posted lines link request "R" to user "U", with zero net spend and no draft. + CreatePostedTravelRequestReport(SpendRequest, PostedExpenseReportHeader, false); + LibraryExpense.UpdateEnableAgentInAgentSetup(true); + ExpenseUser.Get(SpendRequest."Requested For"); + CreateApproverForExpenseUser(ApproverExpenseUser, ExpenseUser); + Assert.AreEqual('', PostedExpenseReportHeader."Spend Request No.", 'The posted header must not link the request in the line-only fixture.'); + VerifyPostedTravelRequestHistory(SpendRequest, PostedExpenseReportHeader); + + // [WHEN] Owner "U" reopens and resubmits "R". + ReleaseSpendRequest.Reopen(SpendRequest); + SpendRequest.Get(SpendRequest."No."); + Assert.AreEqual(SpendRequest.Status::Open, SpendRequest.Status, 'The request must persist as open after reopening.'); + TravelRequestApproval.Submit(SpendRequest, ExpenseUser."No."); + + // [THEN] Request "R" awaits its assigned approver rather than being approved automatically. + SpendRequest.Get(SpendRequest."No."); + Assert.AreEqual(SpendRequest.Status::Released, SpendRequest.Status, SpendReqReleasedMsg); + + // [WHEN] Assigned approver "A" approves "R". + TravelRequestApproval.Approve(SpendRequest, ApproverExpenseUser."No."); + + // [THEN] Approval is persisted without creating a draft or changing posted history. + VerifyReapprovedTravelRequest(SpendRequest, PostedExpenseReportHeader, ExpenseUser."No.", ApproverExpenseUser."No."); + end; + + [Test] + [HandlerFunctions('SpendReqConfirmHandler')] + procedure AutoReapproveTravelRequestWithPostedHistory() + var + SpendRequest: Record "Spend Request"; + ExpenseUser: Record "Expense User"; + PostedExpenseReportHeader: Record "Posted Expense Report Header"; + ReleaseSpendRequest: Codeunit "Release Spend Request"; + TravelRequestApproval: Codeunit "Travel Request Approval"; + begin + // [FEATURE] [AI test 1.0] + // [SCENARIO 626966] Resubmitting a reopened request with the agent disabled preserves posted history without a new report. + Initialize(); + + // [GIVEN] Request "R" has posted history for user "U", zero net spend, no draft, and the agent disabled. + LibraryExpense.UpdateEnableAgentInAgentSetup(false); + CreatePostedTravelRequestReport(SpendRequest, PostedExpenseReportHeader, true); + ExpenseUser.Get(SpendRequest."Requested For"); + VerifyPostedTravelRequestHistory(SpendRequest, PostedExpenseReportHeader); + + // [WHEN] Owner "U" reopens and resubmits "R". + ReleaseSpendRequest.Reopen(SpendRequest); + SpendRequest.Get(SpendRequest."No."); + Assert.AreEqual(SpendRequest.Status::Open, SpendRequest.Status, 'The request must persist as open after reopening.'); + TravelRequestApproval.Submit(SpendRequest, ExpenseUser."No."); + + // [THEN] Automatic approval is persisted without creating a draft or changing posted history. + VerifyReapprovedTravelRequest(SpendRequest, PostedExpenseReportHeader, ExpenseUser."No.", ''); + end; + + [Test] + procedure ApproveTravelRequestPageAction() + var + ExpenseReportHeader: Record "Expense Report Header"; + SpendRequest: Record "Spend Request"; + ExpenseUser: Record "Expense User"; + ApproverExpenseUser: Record "Expense User"; + TravelRequestsAPI: Page "Travel Requests API"; + ActionContext: WebServiceActionContext; + begin + // [SCENARIO] The approval page procedure approves the request and records the approving user. + Initialize(); + + // [GIVEN] A released travel request and its assigned approver. + LibraryExpense.UpdateEnableAgentInAgentSetup(true); + CreateReleasableSpendRequest(SpendRequest, ExpenseUser); + CreateApproverForExpenseUser(ApproverExpenseUser, ExpenseUser); + LibraryExpense.SetSpendRequestStatus(SpendRequest, SpendRequest.Status::Released); + TravelRequestsAPI.SetRecord(SpendRequest); + + // [WHEN] The page procedure is invoked directly, without an HTTP request. + TravelRequestsAPI.ApproveTravelRequest(ActionContext, ApproverExpenseUser."No."); + + // [THEN] The request is approved with its audit fields and a linked report. + Assert.AreEqual(Format(WebServiceActionResultCode::Updated), Format(ActionContext.GetResultCode()), TravelRequestActionResultMsg); + SpendRequest.Get(SpendRequest."No."); + Assert.AreEqual(SpendRequest.Status::Approved, SpendRequest.Status, 'The travel request should be approved through the page action.'); + Assert.AreEqual(UserSecurityId(), SpendRequest."Approved/Rejected by User ID", 'The approving user should be recorded.'); + Assert.AreEqual(ApproverExpenseUser."No.", SpendRequest."Approval Expense User No.", 'The approving expense user should be recorded.'); + Assert.AreNotEqual(0DT, SpendRequest."Approved/Rejected At", 'The page action approval date and time should be recorded.'); + ExpenseReportHeader.SetRange("Spend Request No.", SpendRequest."No."); + Assert.RecordIsNotEmpty(ExpenseReportHeader); + end; + + [Test] + procedure CreateExpenseReportPageActionRecreatesDeletedReport() + var + ExpenseReportHeader: Record "Expense Report Header"; + SpendRequest: Record "Spend Request"; + ExpenseUser: Record "Expense User"; + TravelRequestsAPI: Page "Travel Requests API"; + ActionContext: WebServiceActionContext; + begin + // [SCENARIO] An approved travel request can recreate its deleted expense report through the API action. + Initialize(); + + // [GIVEN] An approved travel request whose automatically created report was deleted. + CreateReleasableSpendRequest(SpendRequest, ExpenseUser); + LibraryExpense.SetSpendRequestStatus(SpendRequest, SpendRequest.Status::Approved); + ExpenseReportHeader.CreateFromApprovedTravelRequest(SpendRequest); + ExpenseReportHeader.SetRange("Spend Request No.", SpendRequest."No."); + ExpenseReportHeader.FindFirst(); + ExpenseReportHeader.Delete(true); + SetOwnerScopedTravelRequest(TravelRequestsAPI, SpendRequest, ExpenseUser.SystemId); + + // [WHEN] The create expense report action is invoked. + TravelRequestsAPI.CreateExpenseReport(ActionContext); + + // [THEN] A new linked report is returned for the requested Expense User. + Assert.AreEqual(Format(WebServiceActionResultCode::Created), Format(ActionContext.GetResultCode()), 'The action must return a created result.'); + ExpenseReportHeader.SetRange("Spend Request No.", SpendRequest."No."); + ExpenseReportHeader.FindFirst(); + ExpenseReportHeader.TestField("Expense User No.", ExpenseUser."No."); + end; + + [Test] + procedure CreateExpenseReportPageActionRequiresApprovedTravelRequest() + var + SpendRequest: Record "Spend Request"; + ExpenseUser: Record "Expense User"; + TravelRequestsAPI: Page "Travel Requests API"; + ActionContext: WebServiceActionContext; + begin + // [SCENARIO] A report cannot be recreated before the travel request is approved. + Initialize(); + + // [GIVEN] An open travel request with a requested Expense User. + CreateReleasableSpendRequest(SpendRequest, ExpenseUser); + SetOwnerScopedTravelRequest(TravelRequestsAPI, SpendRequest, ExpenseUser.SystemId); + + // [WHEN] The create expense report action is invoked. + asserterror TravelRequestsAPI.CreateExpenseReport(ActionContext); + + // [THEN] The action explains that approval is required. + Assert.ExpectedError(StrSubstNo(TravelRequestMustBeApprovedErr, SpendRequest."No.")); + end; + + [Test] + procedure CreateExpenseReportPageActionRejectsExistingLinkedReport() + var + ExpenseReportHeader: Record "Expense Report Header"; + UnrelatedExpenseReport: Record "Expense Report Header"; + OtherTravelerExpenseReport: Record "Expense Report Header"; + SpendRequest: Record "Spend Request"; + ExpenseUser: Record "Expense User"; + OtherExpenseUser: Record "Expense User"; + TravelRequestsAPI: Page "Travel Requests API"; + ActionContext: WebServiceActionContext; + begin + // [SCENARIO] A second report cannot be created while one is already linked. + Initialize(); + + // [GIVEN] Earlier reports for another request and another traveler must not be used in the error. + CreateReleasableSpendRequest(SpendRequest, ExpenseUser); + LibraryExpense.CreateExpenseUser(OtherExpenseUser); + LibraryExpense.CreateTraveler(SpendRequest."No.", OtherExpenseUser."No."); + LibraryExpense.SetSpendRequestStatus(SpendRequest, SpendRequest.Status::Approved); + LibraryExpense.CreateExpenseReport(UnrelatedExpenseReport, ExpenseUser."No.", '', ''); + LibraryExpense.CreateExpenseReport(OtherTravelerExpenseReport, OtherExpenseUser."No.", '', ''); + OtherTravelerExpenseReport.SetHideValidationDialog(true); + OtherTravelerExpenseReport.Validate("Spend Request No.", SpendRequest."No."); + OtherTravelerExpenseReport.Modify(true); + + // [GIVEN] The requested user also has a report linked to this request. + ExpenseReportHeader.CreateFromApprovedTravelRequest(SpendRequest); + ExpenseReportHeader.SetRange("Spend Request No.", SpendRequest."No."); + ExpenseReportHeader.SetRange("Expense User No.", SpendRequest."Requested For"); + ExpenseReportHeader.FindFirst(); + SetOwnerScopedTravelRequest(TravelRequestsAPI, SpendRequest, ExpenseUser.SystemId); + + // [WHEN] The create expense report action is invoked. + asserterror TravelRequestsAPI.CreateExpenseReport(ActionContext); + + // [THEN] The action identifies the Expense User, existing report, and travel request. + Assert.ExpectedError( + StrSubstNo(ExpenseReportAlreadyLinkedErr, ExpenseUser."No.", ExpenseReportHeader."No.", SpendRequest."No.")); + end; + + [Test] + [HandlerFunctions('SpendReqConfirmHandler')] + procedure CreateExpenseReportRejectsPostedHeader() + var + SpendRequest: Record "Spend Request"; + PostedExpenseReportHeader: Record "Posted Expense Report Header"; + begin + // [SCENARIO] Posting a linked report must not permit recreation for the same traveler. + Initialize(); + CreatePostedTravelRequestReport(SpendRequest, PostedExpenseReportHeader, true); + Assert.AreEqual(SpendRequest."No.", PostedExpenseReportHeader."Spend Request No.", 'The posted header must link the request.'); + Commit(); + AssertPostedReportPreventsRecreation(SpendRequest, PostedExpenseReportHeader); + end; + + [Test] + [HandlerFunctions('SpendReqConfirmHandler')] + procedure CreateExpenseReportRejectsPostedLine() + var + SpendRequest: Record "Spend Request"; + PostedExpenseReportHeader: Record "Posted Expense Report Header"; + begin + // [SCENARIO] A posted line-only travel request link also prevents recreation. + Initialize(); + CreatePostedTravelRequestReport(SpendRequest, PostedExpenseReportHeader, false); + Assert.AreEqual('', PostedExpenseReportHeader."Spend Request No.", 'Only the posted lines must link the request.'); + Commit(); + AssertPostedReportPreventsRecreation(SpendRequest, PostedExpenseReportHeader); + end; + + [Test] + [HandlerFunctions('SpendReqConfirmHandler')] + procedure CreateExpenseReportAllowsOtherPostedTraveler() + var + SpendRequest: Record "Spend Request"; + ExpenseUser: Record "Expense User"; + OtherExpenseUser: Record "Expense User"; + ExpenseReportHeader: Record "Expense Report Header"; + PostedExpenseReportHeader: Record "Posted Expense Report Header"; + TravelRequestsAPI: Page "Travel Requests API"; + ActionContext: WebServiceActionContext; + begin + // [SCENARIO] A posted report for another traveler does not prevent a Requested For report. + Initialize(); + CreatePostedTravelRequestReport(SpendRequest, PostedExpenseReportHeader, true); + ExpenseUser.Get(PostedExpenseReportHeader."Expense User No."); + LibraryExpense.CreateExpenseUser(OtherExpenseUser); + LibraryExpense.SetSpendRequestStatus(SpendRequest, SpendRequest.Status::Open); + SpendRequest.Validate("Requested For", OtherExpenseUser."No."); + SpendRequest.Modify(true); + LibraryExpense.SetSpendRequestStatus(SpendRequest, SpendRequest.Status::Approved); + SetOwnerScopedTravelRequest(TravelRequestsAPI, SpendRequest, ExpenseUser.SystemId); + + TravelRequestsAPI.CreateExpenseReport(ActionContext); + + Assert.AreEqual(Format(WebServiceActionResultCode::Created), Format(ActionContext.GetResultCode()), 'A different traveler must be able to create a report.'); + ExpenseReportHeader.SetRange("Spend Request No.", SpendRequest."No."); + ExpenseReportHeader.FindFirst(); + ExpenseReportHeader.TestField("Expense User No.", OtherExpenseUser."No."); + Assert.IsTrue(PostedExpenseReportHeader.Get(PostedExpenseReportHeader."No."), 'The other traveler''s posted report must remain.'); + end; + + local procedure AssertPostedReportPreventsRecreation(SpendRequest: Record "Spend Request"; PostedExpenseReportHeader: Record "Posted Expense Report Header") + var + ExpenseUser: Record "Expense User"; + ExpenseReportHeader: Record "Expense Report Header"; + TravelRequestsAPI: Page "Travel Requests API"; + ActionContext: WebServiceActionContext; + begin + ExpenseUser.Get(PostedExpenseReportHeader."Expense User No."); + SpendRequest.TestField(Status, SpendRequest.Status::Approved); + ExpenseReportHeader.SetRange("Spend Request No.", SpendRequest."No."); + Assert.RecordIsEmpty(ExpenseReportHeader); + SetOwnerScopedTravelRequest(TravelRequestsAPI, SpendRequest, ExpenseUser.SystemId); + + asserterror TravelRequestsAPI.CreateExpenseReport(ActionContext); + + Assert.ExpectedError(StrSubstNo( + PostedReportAlreadyLinkedErr, ExpenseUser."No.", PostedExpenseReportHeader."No.", SpendRequest."No.")); + Assert.RecordIsEmpty(ExpenseReportHeader); + Assert.IsTrue(PostedExpenseReportHeader.Get(PostedExpenseReportHeader."No."), 'Posted history must remain unchanged.'); + end; + + [Test] + procedure CreateExpenseReportPageActionRequiresOwnerScope() + var + SpendRequest: Record "Spend Request"; + ExpenseUser: Record "Expense User"; + TravelRequestsAPI: Page "Travel Requests API"; + ActionContext: WebServiceActionContext; + begin + // [SCENARIO] Report recreation cannot be invoked through an unscoped travel request route. + Initialize(); + + // [GIVEN] An approved travel request without an owner-scoped API filter. + CreateReleasableSpendRequest(SpendRequest, ExpenseUser); + LibraryExpense.SetSpendRequestStatus(SpendRequest, SpendRequest.Status::Approved); + TravelRequestsAPI.SetRecord(SpendRequest); + + // [WHEN] The create expense report action is invoked. + asserterror TravelRequestsAPI.CreateExpenseReport(ActionContext); - // [THEN] Validation fails because the user is not a traveler. - Assert.ExpectedError(NotTravelerErr); + // [THEN] The action requires the owning Expense User route. + Assert.ExpectedError(OwnerScopeRequiredErr); end; [Test] - procedure ValidateSpendReqNoFailsWhenNotApproved() + procedure CreateExpenseReportPageActionRequiresRequestedFor() var SpendRequest: Record "Spend Request"; ExpenseUser: Record "Expense User"; - ExpenseReportLine: Record "Expense Report Line"; + TravelRequestsAPI: Page "Travel Requests API"; + ActionContext: WebServiceActionContext; begin - // [SCENARIO 616928] A spend request that is not Approved cannot be selected on a line. + // [SCENARIO] A report cannot be created without a requested Expense User. Initialize(); - // [GIVEN] A refundable expense report line for an expense user. - CreateExpenseReportWithRefundableLine(ExpenseReportLine, ExpenseUser, true); - - // [GIVEN] A Release (not approved) spend request with the user as a traveler. - CreateSpendRequestWithTraveler(SpendRequest, ExpenseUser."No.", SpendRequest.Status::Released); + // [GIVEN] An approved owner-scoped travel request without Requested For. + CreateReleasableSpendRequest(SpendRequest, ExpenseUser); + SpendRequest."Requested For" := ''; + SpendRequest.Modify(); + LibraryExpense.SetSpendRequestStatus(SpendRequest, SpendRequest.Status::Approved); + SetOwnerScopedTravelRequest(TravelRequestsAPI, SpendRequest, ExpenseUser.SystemId); - // [WHEN] The spend request is selected on the line. - asserterror ExpenseReportLine.Validate("Spend Request No.", SpendRequest."No."); + // [WHEN] The create expense report action is invoked. + asserterror TravelRequestsAPI.CreateExpenseReport(ActionContext); - // [THEN] Validation fails because only approved spend requests are selectable. - Assert.ExpectedErrorCode('DB:NothingInsideFilter'); + // [THEN] The action requires Requested For. + Assert.ExpectedTestFieldError(SpendRequest.FieldCaption("Requested For"), ''); end; [Test] - [HandlerFunctions('SpendReqConfirmHandler')] - procedure SetRefundableFalseClearsLinkedSpendReq() + procedure CreateExpenseReportPageActionRejectsDifferentOwnerScope() var SpendRequest: Record "Spend Request"; ExpenseUser: Record "Expense User"; - ExpenseReportLine: Record "Expense Report Line"; + DifferentExpenseUser: Record "Expense User"; + TravelRequestsAPI: Page "Travel Requests API"; + ActionContext: WebServiceActionContext; begin - // [SCENARIO 616928] Making a line non-refundable clears its linked spend request. + // [SCENARIO] A report cannot be created through another Expense User's route. Initialize(); - // [GIVEN] A refundable expense report line for an expense user. - CreateExpenseReportWithRefundableLine(ExpenseReportLine, ExpenseUser, true); - - // [GIVEN] An approved spend request with the user as a traveler. - CreateSpendRequestWithTraveler(SpendRequest, ExpenseUser."No.", SpendRequest.Status::Approved); - - // [GIVEN] The spend request is linked to the line. - ExpenseReportLine.Validate("Spend Request No.", SpendRequest."No."); - ExpenseReportLine.Modify(true); + // [GIVEN] An approved travel request scoped through a different Expense User. + CreateReleasableSpendRequest(SpendRequest, ExpenseUser); + LibraryExpense.CreateExpenseUser(DifferentExpenseUser); + LibraryExpense.SetSpendRequestStatus(SpendRequest, SpendRequest.Status::Approved); + SetOwnerScopedTravelRequest(TravelRequestsAPI, SpendRequest, DifferentExpenseUser.SystemId); - // [WHEN] The line is set to non-refundable. - ExpenseReportLine.Validate(Refundable, false); + // [WHEN] The create expense report action is invoked. + asserterror TravelRequestsAPI.CreateExpenseReport(ActionContext); - // [THEN] The spend request link is cleared from the line. - Assert.AreEqual('', ExpenseReportLine."Spend Request No.", SpendReqClearedMsg); - Assert.IsFalse(ExpenseReportLine."Spend Request Close", SpendReqCloseClearedMsg); + // [THEN] The action rejects the mismatched owner. + Assert.ExpectedError(DifferentExpenseUser."Employee No."); end; [Test] - procedure ReleaseSpendReqFailsMissingRequestedFor() + procedure SubmitTravelRequestPageAction() var SpendRequest: Record "Spend Request"; ExpenseUser: Record "Expense User"; - ReleaseSpendRequest: Codeunit "Release Spend Request"; + TravelRequestsAPI: Page "Travel Requests API"; + ActionContext: WebServiceActionContext; begin - // [SCENARIO 616928] Releasing an expense spend request without "Requested For" fails. + // [SCENARIO] The submission page procedure releases the request and records its submitter. Initialize(); - // [GIVEN] An expense user. - LibraryExpense.CreateExpenseUser(ExpenseUser); - - // [GIVEN] An open spend request with valid dates and the travel policy acknowledged, but no Requested For. - LibraryExpense.CreateSpendRequest(SpendRequest); - SpendRequest.Validate("Expected Start Date", WorkDate()); - SpendRequest.Validate("Expected End Date", WorkDate() + 7); - SpendRequest.Validate("Travel Policy Acknowledgment", true); - SpendRequest.Modify(true); + // [GIVEN] A releasable travel request with the Expense Agent enabled. + LibraryExpense.UpdateEnableAgentInAgentSetup(true); + CreateReleasableSpendRequest(SpendRequest, ExpenseUser); + TravelRequestsAPI.SetRecord(SpendRequest); - // [WHEN] The spend request is Released. - asserterror ReleaseSpendRequest.Release(SpendRequest); + // [WHEN] The page procedure is invoked directly, without an HTTP request. + TravelRequestsAPI.SubmitTravelRequest(ActionContext, ExpenseUser."No."); - // [THEN] Release fails because Requested For is required. - Assert.ExpectedError(FieldRequiredErr); + // [THEN] The request is released with its submission audit fields. + Assert.AreEqual(Format(WebServiceActionResultCode::Updated), Format(ActionContext.GetResultCode()), TravelRequestActionResultMsg); + SpendRequest.Get(SpendRequest."No."); + Assert.AreEqual(SpendRequest.Status::Released, SpendRequest.Status, 'The travel request should be released through the page action.'); + Assert.AreEqual(ExpenseUser."No.", SpendRequest."Submitted By Expense User No.", 'The submitting expense user should be recorded.'); + Assert.AreNotEqual(0DT, SpendRequest."Submitted At", 'The page action submission date and time should be recorded.'); end; [Test] - procedure ReleaseSpendReqFailsMissingExpectedStartDate() + procedure SubmitTravelRequestRejectsDifferentOwner() var SpendRequest: Record "Spend Request"; ExpenseUser: Record "Expense User"; - ReleaseSpendRequest: Codeunit "Release Spend Request"; + DifferentExpenseUser: Record "Expense User"; + TravelRequestApproval: Codeunit "Travel Request Approval"; begin - // [SCENARIO 616928] Releasing an expense spend request without "Expected Start Date" fails. + // [SCENARIO] A different expense user cannot submit another employee's travel request. Initialize(); - // [GIVEN] A releasable spend request. + // [GIVEN] A releasable request and an expense user other than its owner. CreateReleasableSpendRequest(SpendRequest, ExpenseUser); + LibraryExpense.CreateExpenseUser(DifferentExpenseUser); - // [GIVEN] Its expected start date is cleared. - SpendRequest."Expected Start Date" := 0D; - SpendRequest.Modify(); - - // [WHEN] The spend request is Released. - asserterror ReleaseSpendRequest.Release(SpendRequest); + // [WHEN] The other expense user attempts to submit the request. + asserterror TravelRequestApproval.Submit(SpendRequest, DifferentExpenseUser."No."); - // [THEN] Release fails because Expected Start Date is required. - Assert.ExpectedError(FieldRequiredErr); + // [THEN] Submission is rejected because the submitter is not the owner. + Assert.ExpectedError(NotTravelRequestOwnerErr); end; [Test] - procedure ReleaseSpendReqFailsMissingExpectedEndDate() + procedure ApproveTravelRequestRejectsUnassignedApprover() var SpendRequest: Record "Spend Request"; ExpenseUser: Record "Expense User"; - ReleaseSpendRequest: Codeunit "Release Spend Request"; + AssignedApprover: Record "Expense User"; + DifferentApprover: Record "Expense User"; + TravelRequestApproval: Codeunit "Travel Request Approval"; begin - // [SCENARIO 616928] Releasing an expense spend request without "Expected End Date" fails. + // [SCENARIO] An approver cannot approve a travel request assigned to someone else. Initialize(); - // [GIVEN] A releasable spend request. + // [GIVEN] A released request with an assigned approver and another approver. + LibraryExpense.UpdateEnableAgentInAgentSetup(true); CreateReleasableSpendRequest(SpendRequest, ExpenseUser); + CreateApproverForExpenseUser(AssignedApprover, ExpenseUser); + CreateApprover(DifferentApprover); + LibraryExpense.SetSpendRequestStatus(SpendRequest, SpendRequest.Status::Released); - // [GIVEN] Its expected end date is cleared. - SpendRequest."Expected End Date" := 0D; - SpendRequest.Modify(); - - // [WHEN] The spend request is Released. - asserterror ReleaseSpendRequest.Release(SpendRequest); + // [WHEN] The unassigned approver attempts to approve the request. + asserterror TravelRequestApproval.Approve(SpendRequest, DifferentApprover."No."); - // [THEN] Release fails because Expected End Date is required. - Assert.ExpectedError(FieldRequiredErr); + // [THEN] Approval is rejected because the approver is not authorized. + Assert.ExpectedError(NotTravelRequestApproverErr); end; [Test] - procedure ReleaseSpendReqFailsPolicyNotAcknowledged() + procedure RejectTravelRequestStoresReason() var SpendRequest: Record "Spend Request"; ExpenseUser: Record "Expense User"; - ReleaseSpendRequest: Codeunit "Release Spend Request"; + ApproverExpenseUser: Record "Expense User"; + TravelRequestApproval: Codeunit "Travel Request Approval"; + RejectReason: Text; begin - // [SCENARIO 616928] Releasing an expense spend request without acknowledging the travel policy fails. + // [SCENARIO] Rejecting a travel request records the approver and rejection reason. Initialize(); - // [GIVEN] A releasable spend request. + // [GIVEN] A released request, its assigned approver, and a rejection reason. + LibraryExpense.UpdateEnableAgentInAgentSetup(true); CreateReleasableSpendRequest(SpendRequest, ExpenseUser); + CreateApproverForExpenseUser(ApproverExpenseUser, ExpenseUser); + LibraryExpense.SetSpendRequestStatus(SpendRequest, SpendRequest.Status::Released); + RejectReason := 'The destination is outside the approved travel policy.'; - // [GIVEN] The travel policy acknowledgment is cleared. - SpendRequest.Validate("Travel Policy Acknowledgment", false); - SpendRequest.Modify(true); - - // [WHEN] The spend request is Released. - asserterror ReleaseSpendRequest.Release(SpendRequest); + // [WHEN] The approver rejects the request. + TravelRequestApproval.Reject(SpendRequest, ApproverExpenseUser."No.", RejectReason); - // [THEN] Release fails because the travel policy must be acknowledged. - Assert.ExpectedError(PolicyErr); + // [THEN] The request is rejected and retains the approver and reason. + SpendRequest.Get(SpendRequest."No."); + Assert.AreEqual(SpendRequest.Status::Rejected, SpendRequest.Status, 'The travel request should be rejected.'); + Assert.AreEqual(ApproverExpenseUser."No.", SpendRequest."Approval Expense User No.", 'The rejecting expense user should be recorded.'); + Assert.AreEqual(RejectReason, SpendRequest."Rejection Reason", 'The rejection reason should be recorded.'); end; [Test] - procedure ReleaseSpendReqFailsIntlNoDestination() + procedure RejectTravelRequestPageAction() var SpendRequest: Record "Spend Request"; ExpenseUser: Record "Expense User"; - ReleaseSpendRequest: Codeunit "Release Spend Request"; + ApproverExpenseUser: Record "Expense User"; + TravelRequestsAPI: Page "Travel Requests API"; + ActionContext: WebServiceActionContext; + RejectReason: Text; begin - // [SCENARIO 616928] Releasing an international expense spend request without a destination country fails. + // [SCENARIO] The rejection page procedure records the rejecting user, reason, and timestamp. Initialize(); - // [GIVEN] A releasable spend request. + // [GIVEN] A released request, its assigned approver, and a rejection reason. + LibraryExpense.UpdateEnableAgentInAgentSetup(true); CreateReleasableSpendRequest(SpendRequest, ExpenseUser); + CreateApproverForExpenseUser(ApproverExpenseUser, ExpenseUser); + LibraryExpense.SetSpendRequestStatus(SpendRequest, SpendRequest.Status::Released); + RejectReason := 'The destination is outside the approved travel policy.'; + TravelRequestsAPI.SetRecord(SpendRequest); - // [GIVEN] It is flagged as international travel with no destination country. - SpendRequest.Validate("International Travel", true); - SpendRequest.Modify(true); - - // [WHEN] The spend request is Released. - asserterror ReleaseSpendRequest.Release(SpendRequest); + // [WHEN] The page procedure is invoked directly, without an HTTP request. + TravelRequestsAPI.RejectTravelRequest(ActionContext, ApproverExpenseUser."No.", RejectReason); - // [THEN] Release fails because a destination country is required for international travel. - Assert.ExpectedError(DestinationErr); + // [THEN] The action returns Updated and the request records the rejection details. + Assert.AreEqual(Format(WebServiceActionResultCode::Updated), Format(ActionContext.GetResultCode()), TravelRequestActionResultMsg); + SpendRequest.Get(SpendRequest."No."); + Assert.AreEqual(SpendRequest.Status::Rejected, SpendRequest.Status, TravelRequestRejectedMsg); + Assert.AreEqual(UserSecurityId(), SpendRequest."Approved/Rejected by User ID", TravelRequestRejectionUserMsg); + Assert.AreEqual(ApproverExpenseUser."No.", SpendRequest."Approval Expense User No.", TravelRequestRejectionExpenseUserMsg); + Assert.AreEqual(RejectReason, SpendRequest."Rejection Reason", TravelRequestRejectionReasonMsg); + Assert.AreNotEqual(0DT, SpendRequest."Approved/Rejected At", TravelRequestRejectionDateMsg); end; [Test] - procedure ReleaseSpendReqFailsNoTravelers() + procedure OwnerFilterUsesExpenseUserSystemId() var SpendRequest: Record "Spend Request"; + FilteredTravelRequest: Record "Spend Request"; ExpenseUser: Record "Expense User"; - ReleaseSpendRequest: Codeunit "Release Spend Request"; + OtherExpenseUser: Record "Expense User"; + TravelRequestApproval: Codeunit "Travel Request Approval"; + OwnerSystemId: Guid; + NewExpenseUserNo: Code[20]; begin - // [SCENARIO 616928] Releasing an expense spend request without any travelers fails. + // [SCENARIO] A stable owner identity resolves to the employee number used by the base table. Initialize(); - // [GIVEN] A releasable spend request. - CreateReleasableSpendRequest(SpendRequest, ExpenseUser); + // [GIVEN] A request owned by an expense user's employee, followed by renaming the expense user. + LibraryExpense.CreateExpenseUser(ExpenseUser); + LibraryExpense.CreateExpenseUser(OtherExpenseUser); + LibraryExpense.CreateSpendRequest(SpendRequest); + SpendRequest.Validate("Requested By", ExpenseUser."Employee No."); + SpendRequest.Modify(true); + OwnerSystemId := ExpenseUser.SystemId; + NewExpenseUserNo := CopyStr(Format(CreateGuid()), 1, MaxStrLen(NewExpenseUserNo)); + ExpenseUser.Rename(NewExpenseUserNo); - // [GIVEN] Its travelers are removed. - DeleteTravelers(SpendRequest."No."); + // [WHEN] The original GUID is used to resolve the owner. + TravelRequestApproval.ApplyOwnerFilter(FilteredTravelRequest, OwnerSystemId); + FilteredTravelRequest.SetRange("No.", SpendRequest."No."); - // [WHEN] The spend request is Released. - asserterror ReleaseSpendRequest.Release(SpendRequest); + // [THEN] The owned request remains visible despite the business-number change. + Assert.IsFalse(FilteredTravelRequest.IsEmpty(), 'Renaming the expense user must not break owner navigation.'); + Assert.AreEqual( + ExpenseUser."Employee No.", FilteredTravelRequest.GetRangeMin("Requested By"), + 'Owner scoping must still use the linked employee, not the Expense User number.'); - // [THEN] Release fails because at least one traveler is required. - Assert.ExpectedError(NoTravelersErr); + // [WHEN] The same request is scoped to a different expense user. + TravelRequestApproval.ApplyOwnerFilter(FilteredTravelRequest, OtherExpenseUser.SystemId); + + // [THEN] Another user cannot see the original owner's request. + Assert.IsTrue(FilteredTravelRequest.IsEmpty(), 'The GUID scope must not expose another employee''s request.'); end; [Test] - procedure ReleaseSpendReqAutoApprovesWhenAgentDisabled() + procedure OwnerFilterRejectsUnknownExpenseUser() var - SpendRequest: Record "Spend Request"; + FilteredTravelRequest: Record "Spend Request"; ExpenseUser: Record "Expense User"; - ReleaseSpendRequest: Codeunit "Release Spend Request"; + TravelRequestApproval: Codeunit "Travel Request Approval"; begin - // [SCENARIO 616928] With the agent disabled, releasing an expense spend request that meets all prerequisites approves it automatically. + // [SCENARIO] An unknown owner GUID fails instead of falling back to an unscoped query. Initialize(); - // [GIVEN] A releasable spend request with every prerequisite satisfied. - CreateReleasableSpendRequest(SpendRequest, ExpenseUser); + // [WHEN] A nonexistent expense user is used as the owner scope. + asserterror TravelRequestApproval.ApplyOwnerFilter(FilteredTravelRequest, CreateGuid()); - // [WHEN] The spend request is Released. - ReleaseSpendRequest.Release(SpendRequest); + // [THEN] The missing-record error is propagated. + Assert.ExpectedErrorCode('DB:RecordNotFound'); + Assert.ExpectedError(ExpenseUser.TableCaption()); + end; - // [THEN] The spend request is approved automatically because there is no agent to approve it. - SpendRequest.Get(SpendRequest."No."); - Assert.AreEqual(SpendRequest.Status::Approved, SpendRequest.Status, SpendReqApprovedMsg); + [Test] + procedure ApproverFilterReturnsAssignedTravelRequests() + var + AssignedTravelRequest: Record "Spend Request"; + OtherTravelRequest: Record "Spend Request"; + FilteredTravelRequest: Record "Spend Request"; + AssignedExpenseUser: Record "Expense User"; + OtherExpenseUser: Record "Expense User"; + AssignedApprover: Record "Expense User"; + OtherApprover: Record "Expense User"; + TravelRequestApproval: Codeunit "Travel Request Approval"; + ApproverSystemId: Guid; + begin + // [SCENARIO] The approver filter includes assigned requests and excludes other approvers' requests. + Initialize(); + + // [GIVEN] Two released travel requests assigned to different approvers. + LibraryExpense.UpdateEnableAgentInAgentSetup(true); + CreateReleasableSpendRequest(AssignedTravelRequest, AssignedExpenseUser); + CreateApproverForExpenseUser(AssignedApprover, AssignedExpenseUser); + LibraryExpense.SetSpendRequestStatus(AssignedTravelRequest, AssignedTravelRequest.Status::Released); + CreateReleasableSpendRequest(OtherTravelRequest, OtherExpenseUser); + CreateApproverForExpenseUser(OtherApprover, OtherExpenseUser); + LibraryExpense.SetSpendRequestStatus(OtherTravelRequest, OtherTravelRequest.Status::Released); + + // [GIVEN] The assigned approver is renamed without changing its stable identity. + ApproverSystemId := AssignedApprover.SystemId; + AssignedApprover.Rename(CopyStr(Format(CreateGuid()), 1, MaxStrLen(AssignedApprover."No."))); + + // [WHEN] The first approver's filter is applied to pending travel requests. + FilteredTravelRequest.SetRange("Document Type", FilteredTravelRequest."Document Type"::"Travel Request"); + FilteredTravelRequest.SetRange(Status, FilteredTravelRequest.Status::Released); + TravelRequestApproval.ApplyApproverFilter(FilteredTravelRequest, ApproverSystemId); + + // [THEN] Only the request assigned to that approver is visible. + FilteredTravelRequest.SetRange("No.", AssignedTravelRequest."No."); + Assert.IsFalse(FilteredTravelRequest.IsEmpty(), AssignedTravelRequestVisibleMsg); + FilteredTravelRequest.SetRange("No.", OtherTravelRequest."No."); + Assert.IsTrue(FilteredTravelRequest.IsEmpty(), UnassignedTravelRequestHiddenMsg); end; [Test] - procedure ReleaseSpendReqStaysReleasedWhenAgentEnabled() + procedure ApproverFilterReturnsDefaultApproverTravelRequests() + begin + // [SCENARIO] The default approver sees requests without an explicit approval assignment. + VerifyDefaultApproverFilter('', ''); + end; + + [Test] + procedure DefaultApproverFilterQuotesWildcardUserNo() + begin + // [SCENARIO] A literal wildcard user number must not expose another approver's requests. + VerifyDefaultApproverFilter('*', 'TR-OTHER'); + end; + + [Test] + procedure DefaultApproverFilterQuotesPipeUserNo() + begin + // [SCENARIO] A pipe in a user number must not become an OR filter for other users. + VerifyDefaultApproverFilter('TR-A|TR-B', 'TR-A'); + end; + + [Test] + procedure ApproverFilterReturnsEmptyForApproverWithoutRequests() var SpendRequest: Record "Spend Request"; + FilteredTravelRequest: Record "Spend Request"; ExpenseUser: Record "Expense User"; - ReleaseSpendRequest: Codeunit "Release Spend Request"; + AssignedApprover: Record "Expense User"; + ApproverWithoutRequests: Record "Expense User"; + TravelRequestApproval: Codeunit "Travel Request Approval"; begin - // [SCENARIO 616928] With the agent enabled, releasing an expense spend request leaves it Released for the agent to approve. + // [SCENARIO] An approver without assigned requests receives an empty filtered set. Initialize(); - LibraryExpense.UpdateEnableAgentInAgentSetup(true); - // [GIVEN] A releasable spend request with every prerequisite satisfied. + // [GIVEN] A released request assigned to someone else and no default approver. + LibraryExpense.UpdateEnableAgentInAgentSetup(true); + SetDefaultApprover(''); CreateReleasableSpendRequest(SpendRequest, ExpenseUser); + CreateApproverForExpenseUser(AssignedApprover, ExpenseUser); + LibraryExpense.SetSpendRequestStatus(SpendRequest, SpendRequest.Status::Released); + CreateApprover(ApproverWithoutRequests); - // [WHEN] The spend request is Released. - ReleaseSpendRequest.Release(SpendRequest); + // [WHEN] The unassigned approver's filter is applied to pending travel requests. + FilteredTravelRequest.SetRange("Document Type", FilteredTravelRequest."Document Type"::"Travel Request"); + FilteredTravelRequest.SetRange(Status, FilteredTravelRequest.Status::Released); + TravelRequestApproval.ApplyApproverFilter(FilteredTravelRequest, ApproverWithoutRequests.SystemId); - // [THEN] The spend request stays Released. - SpendRequest.Get(SpendRequest."No."); - Assert.AreEqual(SpendRequest.Status::Released, SpendRequest.Status, SpendReqReleasedMsg); + // [THEN] No requests are visible. + Assert.IsTrue(FilteredTravelRequest.IsEmpty(), ApproverWithoutRequestsMsg); end; [Test] @@ -625,6 +1852,26 @@ codeunit 148339 "Spend Request Test" Assert.RecordCount(Traveler, 0); end; + [Test] + procedure RequestedForBeforeInsertAddsTravelerAfterInsert() + var + SpendRequest: Record "Spend Request"; + ExpenseUser: Record "Expense User"; + Traveler: Record Traveler; + begin + Initialize(); + LibraryExpense.CreateExpenseUser(ExpenseUser); + SpendRequest.Init(); + SpendRequest."Document Type" := SpendRequest."Document Type"::"Travel Request"; + + SpendRequest.Validate("Requested For", ExpenseUser."No."); + SpendRequest.Insert(true); + + Traveler.SetRange("Spend Request No.", SpendRequest."No."); + Traveler.SetRange("Expense User No.", ExpenseUser."No."); + Assert.RecordCount(Traveler, 1); + end; + [Test] [HandlerFunctions('SpendReqConfirmHandler')] procedure ValidateHeaderSpendReqStoresCloseFlagWhenConfirmed() @@ -782,7 +2029,9 @@ codeunit 148339 "Spend Request Test" asserterror ExpenseReportHeader.Preview(ExpenseReportHeader); // [THEN] The preview lists the Spend Request To G/L Link entries (asserted in the page handler). + // Posting preview intentionally exits with Error(''); the handler proves the expected entries were shown. Assert.ExpectedError(''); + Assert.IsTrue(SpendReqPreviewShown, SpendReqLinkPreviewMsg); end; [Test] @@ -801,7 +2050,7 @@ codeunit 148339 "Spend Request Test" LibraryExpense.CreateExpenseCategory(ExpenseCategory, ExpenseCategory."Reimbursement Type"::"Employee Paid", ExpenseCategory."Expense Detail Required"::" "); // [GIVEN] A line whose type is Category. - CreateTravelRequestLine(SpendRequestDetail, SpendRequest."No."); + LibraryExpense.CreateSpendRequestDetail(SpendRequestDetail, SpendRequest."No.", 0); SpendRequestDetail.Validate(Type, SpendRequestDetail.Type::Category); // [WHEN] An expense category is assigned to the line. @@ -826,7 +2075,7 @@ codeunit 148339 "Spend Request Test" // [GIVEN] An open travel request, an expense category, and a Lump Sum line. LibraryExpense.CreateSpendRequest(SpendRequest); LibraryExpense.CreateExpenseCategory(ExpenseCategory, ExpenseCategory."Reimbursement Type"::"Employee Paid", ExpenseCategory."Expense Detail Required"::" "); - CreateTravelRequestLine(SpendRequestDetail, SpendRequest."No."); + LibraryExpense.CreateSpendRequestDetail(SpendRequestDetail, SpendRequest."No.", 0); SpendRequestDetail.Validate(Type, SpendRequestDetail.Type::"Lump Sum"); // [WHEN] Assigning an expense category to the Lump Sum line. @@ -850,7 +2099,7 @@ codeunit 148339 "Spend Request Test" // [GIVEN] A Category line with an expense category assigned. LibraryExpense.CreateSpendRequest(SpendRequest); LibraryExpense.CreateExpenseCategory(ExpenseCategory, ExpenseCategory."Reimbursement Type"::"Employee Paid", ExpenseCategory."Expense Detail Required"::" "); - CreateTravelRequestLine(SpendRequestDetail, SpendRequest."No."); + LibraryExpense.CreateSpendRequestDetail(SpendRequestDetail, SpendRequest."No.", 0); SpendRequestDetail.Validate(Type, SpendRequestDetail.Type::Category); SpendRequestDetail.Validate("Expense Category Code", ExpenseCategory.Code); SpendRequestDetail.Modify(true); @@ -879,13 +2128,13 @@ codeunit 148339 "Spend Request Test" LibraryExpense.CreateExpenseCategory(ExpenseCategory, ExpenseCategory."Reimbursement Type"::"Employee Paid", ExpenseCategory."Expense Detail Required"::" "); // [GIVEN] A Category line with a category. - CreateTravelRequestLine(CategoryLine, SpendRequest."No."); + LibraryExpense.CreateSpendRequestDetail(CategoryLine, SpendRequest."No.", 0); CategoryLine.Validate(Type, CategoryLine.Type::Category); CategoryLine.Validate("Expense Category Code", ExpenseCategory.Code); CategoryLine.Modify(true); // [WHEN] A Lump Sum line is added to the same request. - CreateTravelRequestLine(LumpSumLine, SpendRequest."No."); + LibraryExpense.CreateSpendRequestDetail(LumpSumLine, SpendRequest."No.", 0); LumpSumLine.Validate(Type, LumpSumLine.Type::"Lump Sum"); LumpSumLine.Modify(true); @@ -898,14 +2147,18 @@ codeunit 148339 "Spend Request Test" local procedure Initialize() var + ExpenseApprovalSetup: Record "Expense Approval Setup"; GeneralLedgerSetup: Record "General Ledger Setup"; LibraryERMCountryData: Codeunit "Library - ERM Country Data"; begin LibraryTestInitialize.OnTestInitialize(Codeunit::"Spend Request Test"); + // Clear approval mappings together with the expense users between tests. + ExpenseApprovalSetup.DeleteAll(); LibraryExpense.CleanUpBeforeTesting(); LibraryExpense.CleanTransactionalData(); CloseConfirmCount := 0; CloseConfirmReply := false; + SpendReqPreviewShown := false; GeneralLedgerSetup.Get(); GeneralLedgerSetup."Additional Reporting Currency" := ''; @@ -917,6 +2170,7 @@ codeunit 148339 "Spend Request Test" exit; LibraryTestInitialize.OnBeforeTestSuiteInitialize(Codeunit::"Spend Request Test"); + CreateGeneralPostingSetup(); LibraryERMCountryData.CreateVATData(); LibraryERMCountryData.UpdateGeneralPostingSetup(); LibraryERMCountryData.CreateGeneralPostingSetupData(); @@ -932,6 +2186,17 @@ codeunit 148339 "Spend Request Test" LibraryTestInitialize.OnAfterTestSuiteInitialize(Codeunit::"Spend Request Test"); end; + local procedure CreateGeneralPostingSetup() + var + GeneralPostingSetup: Record "General Posting Setup"; + begin + // Country setup helpers need a complete posting setup even without demo data. + LibraryERM.CreateGeneralPostingSetupInvt(GeneralPostingSetup); + LibraryERM.SetGeneralPostingSetupSalesAccounts(GeneralPostingSetup); + LibraryERM.SetGeneralPostingSetupPurchAccounts(GeneralPostingSetup); + GeneralPostingSetup.Modify(true); + end; + local procedure CreateExpenseReportWithRefundableLine(var ExpenseReportLine: Record "Expense Report Line"; var ExpenseUser: Record "Expense User"; Refundable: Boolean) var ExpenseCategory: Record "Expense Category"; @@ -953,22 +2218,18 @@ codeunit 148339 "Spend Request Test" LibraryExpense.SetSpendRequestStatus(SpendRequest, NewStatus); end; - local procedure CreateTravelRequestLine(var SpendRequestDetail: Record "Spend Request Detail"; SpendRequestNo: Code[20]) + local procedure PrepareTravelRequestWithAPIDates(var SpendRequest: Record "Spend Request"; StartDate: Date; EndDate: Date; StartDateProvided: Boolean; EndDateProvided: Boolean) begin - SpendRequestDetail.Init(); - SpendRequestDetail."Spend Request No." := SpendRequestNo; - SpendRequestDetail."Line No." := NextTravelRequestLineNo(SpendRequestNo); - SpendRequestDetail.Insert(true); + Clear(SpendRequest); + SpendRequest.Init(); + SpendRequest."Document Type" := SpendRequest."Document Type"::"Travel Request"; + SpendRequest.SetExpectedDatesForAPIInsert(StartDate, EndDate, StartDateProvided, EndDateProvided); end; - local procedure NextTravelRequestLineNo(SpendRequestNo: Code[20]): Integer - var - SpendRequestDetail: Record "Spend Request Detail"; + local procedure AssertTravelRequestDates(SpendRequest: Record "Spend Request"; StartDate: Date; EndDate: Date) begin - SpendRequestDetail.SetRange("Spend Request No.", SpendRequestNo); - if SpendRequestDetail.FindLast() then - exit(SpendRequestDetail."Line No." + 10000); - exit(10000); + Assert.AreEqual(StartDate, SpendRequest."Expected Start Date", 'The expected start date must match the effective input.'); + Assert.AreEqual(EndDate, SpendRequest."Expected End Date", 'The expected end date must match the effective input.'); end; local procedure CreateReleasableSpendRequest(var SpendRequest: Record "Spend Request"; var ExpenseUser: Record "Expense User") @@ -976,6 +2237,7 @@ codeunit 148339 "Spend Request Test" LibraryExpense.CreateExpenseUser(ExpenseUser); LibraryExpense.CreateSpendRequest(SpendRequest); + SpendRequest.Validate("Requested By", ExpenseUser."Employee No."); SpendRequest.Validate("Requested For", ExpenseUser."No."); SpendRequest.Validate("Expected Start Date", WorkDate()); SpendRequest.Validate("Expected End Date", WorkDate() + 7); @@ -983,6 +2245,146 @@ codeunit 148339 "Spend Request Test" SpendRequest.Modify(true); end; + local procedure CreateApproverForExpenseUser(var ApproverExpenseUser: Record "Expense User"; ExpenseUser: Record "Expense User") + var + ExpenseApprovalSetup: Record "Expense Approval Setup"; + begin + CreateApprover(ApproverExpenseUser); + if ExpenseApprovalSetup.Get(ExpenseUser."No.") then begin + ExpenseApprovalSetup.Validate("Approver No.", ApproverExpenseUser."No."); + ExpenseApprovalSetup.Modify(true); + end else + LibraryExpense.CreateExpenseApprovalSetup(ExpenseApprovalSetup, ExpenseUser."No.", ApproverExpenseUser."No."); + end; + + local procedure CreateApprover(var ApproverExpenseUser: Record "Expense User") + begin + LibraryExpense.CreateExpenseUser(ApproverExpenseUser); + ApproverExpenseUser."Can Approve" := true; + ApproverExpenseUser."User Id For Approvals" := CopyStr(UserId(), 1, MaxStrLen(ApproverExpenseUser."User Id For Approvals")); + ApproverExpenseUser.Modify(true); + end; + + local procedure SetDefaultApprover(ApproverExpenseUserNo: Code[20]) + var + ExpenseAgentSetup: Record "Expense Agent Setup"; + begin + ExpenseAgentSetup.Get(); + ExpenseAgentSetup.Validate("Default Approver No.", ApproverExpenseUserNo); + ExpenseAgentSetup.Modify(true); + end; + + local procedure VerifyDefaultApproverFilter(DefaultExpenseUserNo: Code[20]; OtherExpenseUserNo: Code[20]) + var + DefaultTravelRequest: Record "Spend Request"; + OtherTravelRequest: Record "Spend Request"; + FilteredTravelRequest: Record "Spend Request"; + DefaultExpenseUser: Record "Expense User"; + OtherExpenseUser: Record "Expense User"; + DefaultApprover: Record "Expense User"; + OtherApprover: Record "Expense User"; + TravelRequestApproval: Codeunit "Travel Request Approval"; + begin + Initialize(); + + // [GIVEN] A default approver, an unassigned request, and a request assigned to another approver. + LibraryExpense.UpdateEnableAgentInAgentSetup(true); + CreateApprover(DefaultApprover); + SetDefaultApprover(DefaultApprover."No."); + CreateReleasableSpendRequest(DefaultTravelRequest, DefaultExpenseUser); + if DefaultExpenseUserNo <> '' then begin + DefaultExpenseUser.Rename(DefaultExpenseUserNo); + DefaultTravelRequest.Get(DefaultTravelRequest."No."); + DefaultTravelRequest.TestField("Requested For", DefaultExpenseUserNo); + end; + LibraryExpense.SetSpendRequestStatus(DefaultTravelRequest, DefaultTravelRequest.Status::Released); + CreateReleasableSpendRequest(OtherTravelRequest, OtherExpenseUser); + if OtherExpenseUserNo <> '' then begin + OtherExpenseUser.Rename(OtherExpenseUserNo); + OtherTravelRequest.Get(OtherTravelRequest."No."); + OtherTravelRequest.TestField("Requested For", OtherExpenseUserNo); + end; + CreateApproverForExpenseUser(OtherApprover, OtherExpenseUser); + LibraryExpense.SetSpendRequestStatus(OtherTravelRequest, OtherTravelRequest.Status::Released); + + // [WHEN] The default approver's filter is applied to pending travel requests. + FilteredTravelRequest.SetRange("Document Type", FilteredTravelRequest."Document Type"::"Travel Request"); + FilteredTravelRequest.SetRange(Status, FilteredTravelRequest.Status::Released); + TravelRequestApproval.ApplyApproverFilter(FilteredTravelRequest, DefaultApprover.SystemId); + + // [THEN] Only the literal unassigned user's request is visible, not the other approver's request. + FilteredTravelRequest.SetRange("No.", DefaultTravelRequest."No."); + Assert.IsFalse(FilteredTravelRequest.IsEmpty(), DefaultTravelRequestVisibleMsg); + FilteredTravelRequest.SetRange("No.", OtherTravelRequest."No."); + Assert.IsTrue(FilteredTravelRequest.IsEmpty(), UnassignedTravelRequestHiddenMsg); + end; + + local procedure VerifyPostedTravelRequestHistory(SpendRequest: Record "Spend Request"; ExpectedPostedExpenseReportHeader: Record "Posted Expense Report Header") + var + ExpenseReportHeader: Record "Expense Report Header"; + PostedExpenseReportHeader: Record "Posted Expense Report Header"; + PostedExpenseReportLine: Record "Posted Expense Report Line"; + begin + SpendRequest.Get(SpendRequest."No."); + Assert.AreEqual(SpendRequest.Status::Approved, SpendRequest.Status, 'The travel request must persist as approved.'); + SpendRequest.CalcFields("Total Spent Amount (LCY)"); + Assert.AreEqual(0, SpendRequest."Total Spent Amount (LCY)", 'The posted history must leave zero net spend.'); + ExpenseReportHeader.SetRange("Spend Request No.", SpendRequest."No."); + Assert.RecordIsEmpty(ExpenseReportHeader); + ExpenseReportHeader.SetRange("Spend Request No."); + ExpenseReportHeader.SetRange("Expense User No.", SpendRequest."Requested For"); + Assert.RecordIsEmpty(ExpenseReportHeader); + + PostedExpenseReportHeader.Get(ExpectedPostedExpenseReportHeader."No."); + Assert.AreEqual(ExpectedPostedExpenseReportHeader.SystemId, PostedExpenseReportHeader.SystemId, 'The original posted report must remain.'); + Assert.AreEqual(ExpectedPostedExpenseReportHeader."Spend Request No.", PostedExpenseReportHeader."Spend Request No.", 'The posted header request link must remain unchanged.'); + Assert.AreEqual(SpendRequest."Requested For", PostedExpenseReportHeader."Expense User No.", 'Posted history must belong to the requested expense user.'); + PostedExpenseReportLine.SetRange("Document No.", PostedExpenseReportHeader."No."); + PostedExpenseReportLine.SetRange("Spend Request No.", SpendRequest."No."); + PostedExpenseReportLine.SetRange("Expense User No.", SpendRequest."Requested For"); + Assert.RecordCount(PostedExpenseReportLine, 1); + PostedExpenseReportLine.CalcSums("Amount (LCY)"); + Assert.AreEqual(0, PostedExpenseReportLine."Amount (LCY)", 'The posted zero-amount line must remain linked to the same request and expense user.'); + end; + + local procedure VerifyReapprovedTravelRequest(SpendRequest: Record "Spend Request"; PostedExpenseReportHeader: Record "Posted Expense Report Header"; SubmitterExpenseUserNo: Code[20]; ApproverExpenseUserNo: Code[20]) + begin + VerifyPostedTravelRequestHistory(SpendRequest, PostedExpenseReportHeader); + SpendRequest.Get(SpendRequest."No."); + Assert.AreEqual(SubmitterExpenseUserNo, SpendRequest."Submitted By Expense User No.", 'The resubmitting expense user must be persisted.'); + Assert.AreNotEqual(0DT, SpendRequest."Submitted At", 'The resubmission date and time must be persisted.'); + Assert.AreEqual(UserSecurityId(), SpendRequest."Approved/Rejected by User ID", 'The approving user must be persisted.'); + Assert.AreEqual(ApproverExpenseUserNo, SpendRequest."Approval Expense User No.", 'The approval expense user must match the approval route.'); + Assert.AreNotEqual(0DT, SpendRequest."Approved/Rejected At", 'The approval date and time must be persisted.'); + Assert.AreEqual('', SpendRequest."Rejection Reason", 'An approved request must not retain a rejection reason.'); + end; + + local procedure CreatePostedTravelRequestReport(var SpendRequest: Record "Spend Request"; var PostedExpenseReportHeader: Record "Posted Expense Report Header"; AssignOnHeader: Boolean) + var + ExpenseReportHeader: Record "Expense Report Header"; + ExpenseReportLine: Record "Expense Report Line"; + ExpenseReportPost: Codeunit "Expense Report-Post"; + begin + if AssignOnHeader then + CreateAndPostExpenseReportWithSpendRequestAssignedOnHeader(ExpenseReportHeader, SpendRequest, 1) + else + CreateAndPostExpenseReportWithSpendRequest(ExpenseReportHeader, SpendRequest, 1); + + ExpenseReportLine.SetRange("Document No.", ExpenseReportHeader."No."); + ExpenseReportLine.FindFirst(); + // Negative non-correction entries do not reverse recorded spend. + // Normal posting of a zero-amount line still creates genuine posted history. + ExpenseReportLine.Validate(Amount, 0); + ExpenseReportLine.Modify(true); + + ExpenseReportHeader.PerformManualRelease(); + ExpenseReportPost.PostExpenseReport(ExpenseReportHeader); + PostedExpenseReportHeader.Get(ExpenseReportHeader."Last Posting No."); + SpendRequest.Get(SpendRequest."No."); + SpendRequest.CalcFields("Total Spent Amount (LCY)"); + Assert.AreEqual(0, SpendRequest."Total Spent Amount (LCY)", 'The posted zero-amount report must leave zero net spend.'); + end; + local procedure CreateAndPostExpenseReportWithSpendRequest(var ExpenseReportHeader: Record "Expense Report Header"; var SpendRequest: Record "Spend Request"; NumberOfLines: Integer) var ExpenseUser: Record "Expense User"; @@ -992,16 +2394,14 @@ codeunit 148339 "Spend Request Test" ExpenseReportLine: Record "Expense Report Line"; Index: Integer; begin - LibraryExpense.CreateExpenseUser(ExpenseUser); + CreateReleasableSpendRequest(SpendRequest, ExpenseUser); Employee.Get(ExpenseUser."Employee No."); LibraryExpense.UpdateExpenseAccountInEmployeePostingGroup(Employee."Employee Posting Group"); LibraryExpense.CreateExpenseCategoryWithSubCategory(ExpenseCategory, ExpenseCategory."Reimbursement Type"::"Employee Paid", ExpenseCategory."Expense Detail Required"::" ", true); LibraryExpense.FindExpensePaymentMethod(ExpensePaymentMethod, ExpensePaymentMethod."Reimbursement Type"::"Employee Paid"); - LibraryExpense.CreateSpendRequest(SpendRequest); LibraryExpense.CreateSpendRequestDetail(SpendRequest."No.", LibraryRandom.RandIntInRange(100000, 100000)); - LibraryExpense.CreateTraveler(SpendRequest."No.", ExpenseUser."No."); LibraryExpense.SetSpendRequestStatus(SpendRequest, SpendRequest.Status::Approved); LibraryExpense.CreateExpenseReport(ExpenseReportHeader, ExpenseUser."No.", '', ''); @@ -1021,16 +2421,14 @@ codeunit 148339 "Spend Request Test" ExpenseReportLine: Record "Expense Report Line"; Index: Integer; begin - LibraryExpense.CreateExpenseUser(ExpenseUser); + CreateReleasableSpendRequest(SpendRequest, ExpenseUser); Employee.Get(ExpenseUser."Employee No."); LibraryExpense.UpdateExpenseAccountInEmployeePostingGroup(Employee."Employee Posting Group"); LibraryExpense.CreateExpenseCategoryWithSubCategory(ExpenseCategory, ExpenseCategory."Reimbursement Type"::"Employee Paid", ExpenseCategory."Expense Detail Required"::" ", true); LibraryExpense.FindExpensePaymentMethod(ExpensePaymentMethod, ExpensePaymentMethod."Reimbursement Type"::"Employee Paid"); - LibraryExpense.CreateSpendRequest(SpendRequest); LibraryExpense.CreateSpendRequestDetail(SpendRequest."No.", LibraryRandom.RandIntInRange(100000, 100000)); - LibraryExpense.CreateTraveler(SpendRequest."No.", ExpenseUser."No."); LibraryExpense.SetSpendRequestStatus(SpendRequest, SpendRequest.Status::Approved); LibraryExpense.CreateExpenseReport(ExpenseReportHeader, ExpenseUser."No.", '', ''); @@ -1096,11 +2494,23 @@ codeunit 148339 "Spend Request Test" Traveler.DeleteAll(); end; + local procedure SetOwnerScopedTravelRequest(var TravelRequestsAPI: Page "Travel Requests API"; var SpendRequest: Record "Spend Request"; ExpenseUserSystemId: Guid) + var + OriginalFilterGroup: Integer; + begin + OriginalFilterGroup := SpendRequest.FilterGroup(4); + SpendRequest.SetRange("Requested By User Id Filter", ExpenseUserSystemId); + SpendRequest.FilterGroup(OriginalFilterGroup); + TravelRequestsAPI.SetTableView(SpendRequest); + TravelRequestsAPI.SetRecord(SpendRequest); + end; + [PageHandler] procedure SpendReqGLPostingPreviewHandler(var GLPostingPreview: TestPage "G/L Posting Preview") begin GLPostingPreview.Filter.SetFilter("Table ID", Format(Database::"Spend Request To G/L Link")); Assert.IsTrue(GLPostingPreview.First(), SpendReqLinkPreviewMsg); + SpendReqPreviewShown := true; GLPostingPreview.OK().Invoke(); end; diff --git a/src/DisabledTests/Expense_Agent_Tests/Expense_Agent_Tests.DisabledTest.json b/src/DisabledTests/Expense_Agent_Tests/Expense_Agent_Tests.DisabledTest.json index 27cd512e9a1..75e9eceba14 100644 --- a/src/DisabledTests/Expense_Agent_Tests/Expense_Agent_Tests.DisabledTest.json +++ b/src/DisabledTests/Expense_Agent_Tests/Expense_Agent_Tests.DisabledTest.json @@ -1,9 +1,89 @@ [ + { + "codeunitId": 148315, + "codeunitName": "Expense Users API Test", + "method": "EmployeesAPICanFilterExpenseUsers" + }, { "codeunitId": 148315, "codeunitName": "Expense Users API Test", "method": "UnlinkedExpenseUserIsHiddenFromAPI" }, + { + "codeunitId": 148347, + "codeunitName": "Travel Requests API Test", + "method": "TravelersAPIMapsEmployeeNumberToExpenseUser" + }, + { + "codeunitId": 148347, + "codeunitName": "Travel Requests API Test", + "method": "TravelersAPIRejectsEmployeeWithoutExpenseUser" + }, + { + "codeunitId": 148347, + "codeunitName": "Travel Requests API Test", + "method": "CreateExpenseReportActionRecreatesDeletedReport" + }, + { + "codeunitId": 148347, + "codeunitName": "Travel Requests API Test", + "method": "ApproveTravelRequestActionCreatesExpenseReport" + }, + { + "codeunitId": 148347, + "codeunitName": "Travel Requests API Test", + "method": "TravelRequestsAPINormalizesCurrency" + }, + { + "codeunitId": 148347, + "codeunitName": "Travel Requests API Test", + "method": "TravelRequestDetailsAPINormalizesCurrency" + }, + { + "codeunitId": 148347, + "codeunitName": "Travel Requests API Test", + "method": "TravelRequestsAPIPreservesAndUpdatesDates" + }, + { + "codeunitId": 148347, + "codeunitName": "Travel Requests API Test", + "method": "TravelRequestsAreScopedByEmployeeNumber" + }, + { + "codeunitId": 148347, + "codeunitName": "Travel Requests API Test", + "method": "ExpenseReportAPIExposesLinkedTravelRequest" + }, + { + "codeunitId": 148347, + "codeunitName": "Travel Requests API Test", + "method": "TravelRequestDetailsAPIExposesTypeAndCategory" + }, + { + "codeunitId": 148347, + "codeunitName": "Travel Requests API Test", + "method": "ApproverViewReturnsOnlyAssignedTravelRequests" + }, + { + "codeunitId": 148347, + "codeunitName": "Travel Requests API Test", + "method": "TravelRequestsAPIAllowsOwnerOnInsertAndUnchangedPatch" + }, + { + "codeunitId": 148347, + "codeunitName": "Travel Requests API Test", + "method": "TravelRequestsAPIRejectsLifecycleFieldChanges" + }, + { + "codeunitId": 148347, + "codeunitName": "Travel Requests API Test", + "method": "LegacySpendRequestsAPIAllowsUnchangedOwner" + }, + { + "codeunitId": 148347, + "codeunitName": "Travel Requests API Test", + "method": "LegacySpendRequestsAPIRejectsTravelRequestOwnerChange" + }, { "codeunitId": 148318, "codeunitName": "Expense Capabilities API Test", @@ -24,6 +104,11 @@ "codeunitName": "Expense Capabilities API Test", "method": "ActivityLogCapabilityEnabledViaAPI" }, + { + "codeunitId": 148318, + "codeunitName": "Expense Capabilities API Test", + "method": "TravelRequestCapabilityEnabledViaAPI" + }, { "codeunitId": 148343, "codeunitName": "Expense Activity Log API Test", diff --git a/src/Layers/W1/BaseApp/Finance/SpendRequest/SpendRequest.Table.al b/src/Layers/W1/BaseApp/Finance/SpendRequest/SpendRequest.Table.al index bfce60e9179..10879e8e50a 100644 --- a/src/Layers/W1/BaseApp/Finance/SpendRequest/SpendRequest.Table.al +++ b/src/Layers/W1/BaseApp/Finance/SpendRequest/SpendRequest.Table.al @@ -18,7 +18,8 @@ table 6840 "Spend Request" Caption = 'Spend Request'; DataClassification = CustomerContent; DataCaptionFields = "No.", Purpose; - Permissions = tabledata "Spend Request Detail" = rimd, + Permissions = tabledata "Spend Request" = m, + tabledata "Spend Request Detail" = rimd, tabledata "Spend Request To G/L Link" = rimd; fields