diff --git a/src/Apps/W1/ExpenseAgent/app/app.json b/src/Apps/W1/ExpenseAgent/app/app.json
index fadf16fcbc8..45e492e888a 100644
--- a/src/Apps/W1/ExpenseAgent/app/app.json
+++ b/src/Apps/W1/ExpenseAgent/app/app.json
@@ -134,7 +134,7 @@
},
{
"from": 7073,
- "to": 7103
+ "to": 7123
}
],
"features": [
diff --git a/src/Apps/W1/ExpenseAgent/app/src/APIs/ActivityLog/ExpenseActivityLogAPI.Page.al b/src/Apps/W1/ExpenseAgent/app/src/APIs/ActivityLog/ExpenseActivityLogAPI.Page.al
new file mode 100644
index 00000000000..d3589c872a9
--- /dev/null
+++ b/src/Apps/W1/ExpenseAgent/app/src/APIs/ActivityLog/ExpenseActivityLogAPI.Page.al
@@ -0,0 +1,230 @@
+// ------------------------------------------------------------------------------------------------
+// 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;
+
+page 7122 "Expense Activity Log API"
+{
+ APIGroup = 'expense';
+ APIPublisher = 'microsoft';
+ APIVersion = 'beta';
+ EntityCaption = 'Expense Activity Log Entry';
+ EntitySetCaption = 'Expense Activity Log Entries';
+ EntityName = 'expenseActivityLogEntry';
+ EntitySetName = 'expenseActivityLogEntries';
+ PageType = API;
+ DelayedInsert = true;
+ SourceTable = "Expense Activity Log Entry";
+ ODataKeyFields = SystemId;
+ DataAccessIntent = ReadOnly;
+ Editable = false;
+ InsertAllowed = false;
+ ModifyAllowed = false;
+ DeleteAllowed = false;
+ Extensible = false;
+ AboutText = 'Provides activity history when scoped through an expense report, posted expense report, or expense user. Direct unscoped access is not allowed. Expense user history requires the historyActorRole filter.';
+
+ layout
+ {
+ area(Content)
+ {
+ repeater(General)
+ {
+ field(id; Rec.SystemId)
+ {
+ Caption = 'ID';
+ }
+ field(entryNumber; Rec."Entry No.")
+ {
+ Caption = 'Entry Number';
+ }
+ field(sourceTableId; Rec."Source Table ID")
+ {
+ Caption = 'Source Table ID';
+ }
+ field(sourceId; Rec."Source Record System ID")
+ {
+ Caption = 'Source ID';
+ }
+ field(subjectTableId; Rec."Subject Table ID")
+ {
+ Caption = 'Subject Table ID';
+ }
+ field(subjectId; Rec."Subject System ID")
+ {
+ Caption = 'Subject ID';
+ }
+ field(documentNumber; Rec."Document No.")
+ {
+ Caption = 'Document Number';
+ }
+ field(documentDescription; Rec."Document Description")
+ {
+ Caption = 'Document Description';
+ }
+ field(eventType; Rec."Event Type")
+ {
+ Caption = 'Event Type';
+ }
+ field(occurredAt; Rec."Occurred At")
+ {
+ Caption = 'Occurred At';
+ }
+ field(initiatedBy; Rec."Initiated By")
+ {
+ Caption = 'Initiated By';
+ }
+ field(actorRole; Rec."Actor Role")
+ {
+ Caption = 'Actor Role';
+ }
+ field(actorTableId; Rec."Actor Table ID")
+ {
+ Caption = 'Actor Table ID';
+ }
+ field(actorId; Rec."Actor Record System ID")
+ {
+ Caption = 'Actor ID';
+ }
+ field(actorDisplayName; Rec."Actor Display Name")
+ {
+ Caption = 'Actor Display Name';
+ }
+ field(comment; Rec.Comment)
+ {
+ Caption = 'Comment';
+ }
+ field(amountLCY; Rec."Amount (LCY)")
+ {
+ Caption = 'Amount (LCY)';
+ }
+ field(currencyLCY; CurrencyLCY)
+ {
+ Caption = 'Currency (LCY)';
+ }
+ field(nonRefundableAmountLCY; Rec."Non-Refundable Amount (LCY)")
+ {
+ Caption = 'Non-Refundable Amount (LCY)';
+ }
+ field(reimbursableAmount; Rec."Reimbursable Amount")
+ {
+ Caption = 'Reimbursable Amount';
+ }
+ field(reimbursableAmountLCY; Rec."Reimbursable Amount (LCY)")
+ {
+ Caption = 'Reimbursable Amount (LCY)';
+ }
+ field(refundableAmount; Rec."Refundable Amount")
+ {
+ Caption = 'Refundable Amount';
+ }
+ field(refundableAmountLCY; Rec."Refundable Amount (LCY)")
+ {
+ Caption = 'Refundable Amount (LCY)';
+ }
+ field(reimbursementCurrencyCode; ReimbursementCurrencyCode)
+ {
+ Caption = 'Reimbursement Currency Code';
+ }
+ field(reimbursementCurrencyFactor; Rec."Reimbursement Currency Factor")
+ {
+ Caption = 'Reimbursement Currency Factor';
+ }
+ field(categories; Rec.Categories)
+ {
+ Caption = 'Categories';
+ }
+ field(attachedReceiptCount; Rec."Attached Receipt Count")
+ {
+ Caption = 'Attached Receipt Count';
+ }
+ field(expenseCount; Rec."Expense Count")
+ {
+ Caption = 'Expense Count';
+ }
+ field(historyActorRole; Rec."History Actor Role Filter")
+ {
+ Caption = 'History Actor Role';
+ }
+ }
+ }
+ }
+
+ var
+ CurrencyHelper: Codeunit "Expense API Currency Helper";
+ CurrencyLCY: Code[10];
+ HistoryScopeApplied: Boolean;
+ ReimbursementCurrencyCode: Code[10];
+ HistoryActorRoleRequiredErr: Label 'The historyActorRole filter must be specified as Submitter or Approver.';
+ ActivityScopeRequiredErr: Label 'Activity log entries must be requested through an expense report, posted expense report, or expense user.';
+
+ trigger OnInit()
+ var
+ ExpenseAgentAPIValidation: Codeunit "Expense Agent API Validation";
+ begin
+ ExpenseAgentAPIValidation.VerifyAgentAccess();
+ end;
+
+ trigger OnOpenPage()
+ begin
+ // Avoid JIT load consistency errors by including fields read in OnAfterGetRecord in the initial record buffer.
+ Rec.AddLoadFields("Reimbursement Currency Code");
+ end;
+
+ trigger OnAfterGetRecord()
+ begin
+ Clear(CurrencyLCY);
+ Clear(ReimbursementCurrencyCode);
+ if Rec."Event Type" in [Rec."Event Type"::Submitted, Rec."Event Type"::Resubmitted, Rec."Event Type"::Posted] then begin
+ CurrencyLCY := CurrencyHelper.GetCurrencyCodeForAPI('');
+ ReimbursementCurrencyCode := CurrencyHelper.GetCurrencyCodeForAPI(Rec."Reimbursement Currency Code");
+ end;
+ end;
+
+ trigger OnFindRecord(Which: Text): Boolean
+ begin
+ ApplyHistoryScope();
+ exit(Rec.Find(Which));
+ end;
+
+ local procedure ApplyHistoryScope()
+ var
+ OriginalFilterGroup: Integer;
+ HasHistoryActorFilters: Boolean;
+ HasSourceFilters: Boolean;
+ HistoryActorRoleFilter: Text;
+ begin
+ if HistoryScopeApplied then
+ exit;
+
+ OriginalFilterGroup := Rec.FilterGroup();
+ HistoryActorRoleFilter := Rec.GetFilter("History Actor Role Filter");
+ Rec.FilterGroup(4);
+ HasSourceFilters :=
+ (Rec.GetFilter("Source Table ID") <> '') and
+ (Rec.GetFilter("Source Record System ID") <> '');
+ HasHistoryActorFilters :=
+ (Rec.GetFilter("History Actor Table ID Filter") <> '') and
+ (Rec.GetFilter("History Actor System ID Filter") <> '');
+ if HistoryActorRoleFilter = '' then
+ HistoryActorRoleFilter := Rec.GetFilter("History Actor Role Filter");
+ Rec.FilterGroup(0);
+ if HasHistoryActorFilters then begin
+ if HistoryActorRoleFilter <> '' then begin
+ Rec.SetCurrentKey("Occurred At", "Entry No.");
+ Rec.Ascending(false);
+ Rec.SetRange("History Subject Match", true)
+ end else
+ Error(HistoryActorRoleRequiredErr);
+ end else
+ if HasSourceFilters then begin
+ Rec.SetCurrentKey("Source Table ID", "Source Record System ID", "Occurred At", "Entry No.");
+ Rec.Ascending(false);
+ end else
+ Error(ActivityScopeRequiredErr);
+ Rec.FilterGroup(OriginalFilterGroup);
+ HistoryScopeApplied := true;
+ end;
+
+}
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 f5be5cdeff4..935c0541ff0 100644
--- a/src/Apps/W1/ExpenseAgent/app/src/APIs/ExpenseReportsAPI.Page.al
+++ b/src/Apps/W1/ExpenseAgent/app/src/APIs/ExpenseReportsAPI.Page.al
@@ -221,6 +221,14 @@ page 6928 "Expense Reports API"
EntitySetName = 'expenseReportLines';
SubPageLink = "Document No." = field("No.");
}
+ part(activityLogEntries; "Expense Activity Log API")
+ {
+ Caption = 'Activity Log Entries';
+ EntityName = 'expenseActivityLogEntry';
+ EntitySetName = 'expenseActivityLogEntries';
+ SubPageLink = "Source Table ID" = const(Database::"Expense Report Header"),
+ "Source Record System ID" = field(SystemId);
+ }
}
}
}
@@ -459,4 +467,5 @@ page 6928 "Expense Reports API"
ActionContext.AddEntityKey(Rec.FieldNo(SystemId), Rec.SystemId);
ActionContext.SetResultCode(WebServiceActionResultCode::Updated);
end;
+
}
\ No newline at end of file
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 8ff6c0ba718..2fc6cdac2ab 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,14 @@ page 6918 "Expense Users API"
SubPageLink = "Expense User No." = field("No.");
}
+ part(activityHistory; "Expense Activity Log API")
+ {
+ EntityName = 'expenseActivityLogEntry';
+ EntitySetName = 'expenseActivityLogEntries';
+ SubPageLink = "History Actor Table ID Filter" = const(Database::"Expense User"),
+ "History Actor System ID Filter" = field(SystemId);
+ }
+
part(approverView; "Approver View API")
{
EntityName = 'approverView';
diff --git a/src/Apps/W1/ExpenseAgent/app/src/APIs/PostedExpenseReportsAPI.Page.al b/src/Apps/W1/ExpenseAgent/app/src/APIs/PostedExpenseReportsAPI.Page.al
index 2526bd241e1..3dc9557518c 100644
--- a/src/Apps/W1/ExpenseAgent/app/src/APIs/PostedExpenseReportsAPI.Page.al
+++ b/src/Apps/W1/ExpenseAgent/app/src/APIs/PostedExpenseReportsAPI.Page.al
@@ -162,6 +162,14 @@ page 6957 "Posted Expense Reports API"
EntitySetName = 'postedExpenseReportLines';
SubPageLink = "Document No." = field("No.");
}
+ part(activityLogEntries; "Expense Activity Log API")
+ {
+ Caption = 'Activity Log Entries';
+ EntityName = 'expenseActivityLogEntry';
+ EntitySetName = 'expenseActivityLogEntries';
+ SubPageLink = "Source Table ID" = const(Database::"Posted Expense Report Header"),
+ "Source Record System ID" = field(SystemId);
+ }
}
}
}
diff --git a/src/Apps/W1/ExpenseAgent/app/src/ActivityLog/Codeunits/ExpenseActivityLogMgt.Codeunit.al b/src/Apps/W1/ExpenseAgent/app/src/ActivityLog/Codeunits/ExpenseActivityLogMgt.Codeunit.al
new file mode 100644
index 00000000000..d213d783721
--- /dev/null
+++ b/src/Apps/W1/ExpenseAgent/app/src/ActivityLog/Codeunits/ExpenseActivityLogMgt.Codeunit.al
@@ -0,0 +1,288 @@
+// ------------------------------------------------------------------------------------------------
+// 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 System.Security.AccessControl;
+
+codeunit 6926 "Expense Activity Log Mgt."
+{
+ Access = Internal;
+ Permissions = tabledata "Expense Activity Log Entry" = rimd,
+ tabledata User = r;
+
+ ///
+ /// Appends an activity entry for an in-flight expense report.
+ ///
+ internal procedure LogExpenseReportEvent(
+ ExpenseReportHeader: Record "Expense Report Header";
+ EventType: Enum "Expense Activity Event Type";
+ InitiatedBy: Enum "Expense Activity Initiator";
+ ActorRole: Enum "Expense Activity Actor Role";
+ ActorExpenseUserNo: Code[20];
+ EventComment: Text
+ ): BigInteger
+ var
+ ExpenseActivityLogEntry: Record "Expense Activity Log Entry";
+ begin
+ InitializeExpenseReportEntry(
+ ExpenseActivityLogEntry, ExpenseReportHeader, EventType, InitiatedBy, ActorRole, EventComment, CurrentDateTime());
+ SetExpenseUserActor(ExpenseActivityLogEntry, ActorExpenseUserNo);
+ exit(InsertExpenseReportEntry(ExpenseActivityLogEntry, ExpenseReportHeader));
+ end;
+
+ ///
+ /// Appends an activity entry performed directly by a Business Central user.
+ ///
+ internal procedure LogExpenseReportEventByBCUser(
+ ExpenseReportHeader: Record "Expense Report Header";
+ EventType: Enum "Expense Activity Event Type";
+ ActorRole: Enum "Expense Activity Actor Role";
+ EventComment: Text
+ ): BigInteger
+ var
+ ExpenseActivityLogEntry: Record "Expense Activity Log Entry";
+ begin
+ InitializeExpenseReportEntry(
+ ExpenseActivityLogEntry, ExpenseReportHeader, EventType,
+ Enum::"Expense Activity Initiator"::User, ActorRole, EventComment, CurrentDateTime());
+ SetBCUserActor(ExpenseActivityLogEntry, UserSecurityId());
+ exit(InsertExpenseReportEntry(ExpenseActivityLogEntry, ExpenseReportHeader));
+ end;
+
+ ///
+ /// Appends the retrospective creation entry when activity tracking starts at first submission.
+ ///
+ internal procedure LogExpenseReportCreatedEvent(ExpenseReportHeader: Record "Expense Report Header"): BigInteger
+ var
+ ExpenseActivityLogEntry: Record "Expense Activity Log Entry";
+ OccurredAt: DateTime;
+ begin
+ OccurredAt := ExpenseReportHeader.SystemCreatedAt;
+ if OccurredAt = 0DT then
+ OccurredAt := CurrentDateTime();
+
+ InitializeExpenseReportEntry(
+ ExpenseActivityLogEntry, ExpenseReportHeader,
+ Enum::"Expense Activity Event Type"::Created,
+ Enum::"Expense Activity Initiator"::User,
+ Enum::"Expense Activity Actor Role"::Submitter,
+ '', OccurredAt);
+
+ // Expense Agent API creation stores the report's Expense User; direct BC creation is identified by SystemCreatedBy.
+ if not IsNullGuid(ExpenseReportHeader."Created By Exp. User Id") then
+ SetExpenseUserActorBySystemID(ExpenseActivityLogEntry, ExpenseReportHeader."Created By Exp. User Id")
+ else
+ SetBCUserActor(ExpenseActivityLogEntry, ExpenseReportHeader.SystemCreatedBy);
+
+ exit(InsertExpenseReportEntry(ExpenseActivityLogEntry, ExpenseReportHeader));
+ end;
+
+ ///
+ /// Reassigns a report's entries to the posted report while preserving event and subject identity.
+ ///
+ internal procedure ReassignExpenseReportEntriesToPosted(
+ ExpenseReportHeader: Record "Expense Report Header";
+ PostedExpenseReportHeader: Record "Posted Expense Report Header"
+ )
+ var
+ ExpenseActivityLogEntry: Record "Expense Activity Log Entry";
+ EntryNumbers: List of [BigInteger];
+ EntryNumber: BigInteger;
+ begin
+ // Capture the primary keys before changing fields used by the source filter.
+ ExpenseActivityLogEntry.SetLoadFields("Entry No.");
+ ExpenseActivityLogEntry.SetRange("Source Table ID", Database::"Expense Report Header");
+ ExpenseActivityLogEntry.SetRange("Source Record System ID", ExpenseReportHeader.SystemId);
+ if ExpenseActivityLogEntry.FindSet(true) then
+ repeat
+ EntryNumbers.Add(ExpenseActivityLogEntry."Entry No.");
+ until ExpenseActivityLogEntry.Next() = 0;
+
+ // Update both source fields together so an entry cannot be left with an intermediate source identity.
+ foreach EntryNumber in EntryNumbers do begin
+ ExpenseActivityLogEntry.Get(EntryNumber);
+ ExpenseActivityLogEntry."Source Table ID" := Database::"Posted Expense Report Header";
+ ExpenseActivityLogEntry."Source Record System ID" := PostedExpenseReportHeader.SystemId;
+ ExpenseActivityLogEntry.Modify(false);
+ end;
+ end;
+
+ ///
+ /// Deletes activity entries sourced from the specified record.
+ ///
+ internal procedure DeleteEntriesForSource(SourceTableID: Integer; SourceRecordSystemID: Guid)
+ var
+ ExpenseActivityLogEntry: Record "Expense Activity Log Entry";
+ begin
+ ExpenseActivityLogEntry.SetRange("Source Table ID", SourceTableID);
+ ExpenseActivityLogEntry.SetRange("Source Record System ID", SourceRecordSystemID);
+ ExpenseActivityLogEntry.DeleteAll();
+ end;
+
+ internal procedure HasEntriesForSource(SourceTableID: Integer; SourceRecordSystemID: Guid): Boolean
+ var
+ ExpenseActivityLogEntry: Record "Expense Activity Log Entry";
+ begin
+ ExpenseActivityLogEntry.SetRange("Source Table ID", SourceTableID);
+ ExpenseActivityLogEntry.SetRange("Source Record System ID", SourceRecordSystemID);
+ exit(not ExpenseActivityLogEntry.IsEmpty());
+ end;
+
+ local procedure InitializeExpenseReportEntry(
+ var ExpenseActivityLogEntry: Record "Expense Activity Log Entry";
+ ExpenseReportHeader: Record "Expense Report Header";
+ EventType: Enum "Expense Activity Event Type";
+ InitiatedBy: Enum "Expense Activity Initiator";
+ ActorRole: Enum "Expense Activity Actor Role";
+ EventComment: Text;
+ OccurredAt: DateTime
+ )
+ begin
+ ExpenseActivityLogEntry.Init();
+ ExpenseActivityLogEntry."Source Table ID" := Database::"Expense Report Header";
+ ExpenseActivityLogEntry."Source Record System ID" := ExpenseReportHeader.SystemId;
+ ExpenseActivityLogEntry."Subject Table ID" := Database::"Expense Report Header";
+ ExpenseActivityLogEntry."Subject System ID" := ExpenseReportHeader.SystemId;
+ ExpenseActivityLogEntry."Document No." := ExpenseReportHeader."No.";
+ ExpenseActivityLogEntry."Document Description" := ExpenseReportHeader.Description;
+ ExpenseActivityLogEntry."Event Type" := EventType;
+ ExpenseActivityLogEntry."Occurred At" := OccurredAt;
+ ExpenseActivityLogEntry."Initiated By" := InitiatedBy;
+ ExpenseActivityLogEntry."Actor Role" := ActorRole;
+ ExpenseActivityLogEntry.Comment := CopyStr(EventComment, 1, MaxStrLen(ExpenseActivityLogEntry.Comment));
+ end;
+
+ local procedure InsertExpenseReportEntry(
+ var ExpenseActivityLogEntry: Record "Expense Activity Log Entry";
+ ExpenseReportHeader: Record "Expense Report Header"
+ ): BigInteger
+ begin
+ if ExpenseActivityLogEntry."Event Type" in [
+ ExpenseActivityLogEntry."Event Type"::Submitted,
+ ExpenseActivityLogEntry."Event Type"::Resubmitted,
+ ExpenseActivityLogEntry."Event Type"::Posted]
+ then begin
+ SetAmountSnapshot(ExpenseActivityLogEntry, ExpenseReportHeader);
+ SetContentsSnapshot(ExpenseActivityLogEntry, ExpenseReportHeader."No.");
+ end;
+
+ ExpenseActivityLogEntry.Insert();
+ exit(ExpenseActivityLogEntry."Entry No.");
+ end;
+
+ local procedure SetExpenseUserActor(var ExpenseActivityLogEntry: Record "Expense Activity Log Entry"; ActorExpenseUserNo: Code[20])
+ var
+ ExpenseUser: Record "Expense User";
+ begin
+ if ActorExpenseUserNo = '' then
+ exit;
+
+ ExpenseUser.SetLoadFields(SystemId, Name);
+ if ExpenseUser.Get(ActorExpenseUserNo) then begin
+ ExpenseActivityLogEntry."Actor Table ID" := Database::"Expense User";
+ ExpenseActivityLogEntry."Actor Record System ID" := ExpenseUser.SystemId;
+ ExpenseActivityLogEntry."Actor Display Name" := ExpenseUser.Name;
+ end;
+ end;
+
+ local procedure SetExpenseUserActorBySystemID(var ExpenseActivityLogEntry: Record "Expense Activity Log Entry"; ActorExpenseUserSystemID: Guid)
+ var
+ ExpenseUser: Record "Expense User";
+ begin
+ ExpenseActivityLogEntry."Actor Table ID" := Database::"Expense User";
+ ExpenseActivityLogEntry."Actor Record System ID" := ActorExpenseUserSystemID;
+
+ ExpenseUser.SetLoadFields(Name);
+ if ExpenseUser.GetBySystemId(ActorExpenseUserSystemID) then
+ ExpenseActivityLogEntry."Actor Display Name" := ExpenseUser.Name;
+ end;
+
+ local procedure SetBCUserActor(var ExpenseActivityLogEntry: Record "Expense Activity Log Entry"; ActorUserSecurityID: Guid)
+ var
+ User: Record User;
+ begin
+ if IsNullGuid(ActorUserSecurityID) then
+ exit;
+
+ User.SetLoadFields(SystemId, "Full Name", "User Name");
+ if not User.Get(ActorUserSecurityID) then
+ exit;
+
+ ExpenseActivityLogEntry."Actor Table ID" := Database::User;
+ ExpenseActivityLogEntry."Actor Record System ID" := User.SystemId;
+ if User."Full Name" <> '' then
+ ExpenseActivityLogEntry."Actor Display Name" :=
+ CopyStr(User."Full Name", 1, MaxStrLen(ExpenseActivityLogEntry."Actor Display Name"))
+ else
+ ExpenseActivityLogEntry."Actor Display Name" :=
+ CopyStr(User."User Name", 1, MaxStrLen(ExpenseActivityLogEntry."Actor Display Name"));
+ end;
+
+ local procedure SetAmountSnapshot(
+ var ExpenseActivityLogEntry: Record "Expense Activity Log Entry";
+ ExpenseReportHeader: Record "Expense Report Header"
+ )
+ begin
+ ExpenseReportHeader.CalcFields(
+ "Amount (LCY)", "Non-Refundable Amount (LCY)",
+ "Reimbursable Amount", "Reimbursable Amount (LCY)",
+ "Refundable Amount", "Refundable Amount (LCY)");
+ ExpenseActivityLogEntry."Amount (LCY)" := ExpenseReportHeader."Amount (LCY)";
+ ExpenseActivityLogEntry."Non-Refundable Amount (LCY)" := ExpenseReportHeader."Non-Refundable Amount (LCY)";
+ ExpenseActivityLogEntry."Reimbursable Amount" := ExpenseReportHeader."Reimbursable Amount";
+ ExpenseActivityLogEntry."Reimbursable Amount (LCY)" := ExpenseReportHeader."Reimbursable Amount (LCY)";
+ ExpenseActivityLogEntry."Refundable Amount" := ExpenseReportHeader."Refundable Amount";
+ ExpenseActivityLogEntry."Refundable Amount (LCY)" := ExpenseReportHeader."Refundable Amount (LCY)";
+ ExpenseActivityLogEntry."Reimbursement Currency Code" := ExpenseReportHeader."Reimbursement Currency Code";
+ ExpenseActivityLogEntry."Reimbursement Currency Factor" := ExpenseReportHeader."Reimbursement Currency Factor";
+ end;
+
+ local procedure SetContentsSnapshot(
+ var ExpenseActivityLogEntry: Record "Expense Activity Log Entry";
+ ExpenseReportNo: Code[20]
+ )
+ var
+ ExpenseReportLine: Record "Expense Report Line";
+ Categories: JsonArray;
+ CategoryCodes: List of [Code[20]];
+ CategoriesText: Text;
+ CandidateCategoriesText: Text;
+ CategoriesTruncated: Boolean;
+ begin
+ ExpenseReportLine.SetLoadFields("Expense Category", "Receipt Attached");
+ ExpenseReportLine.SetRange("Document No.", ExpenseReportNo);
+ if ExpenseReportLine.FindSet() then
+ repeat
+ ExpenseActivityLogEntry."Expense Count" += 1;
+ if ExpenseReportLine."Receipt Attached" then
+ ExpenseActivityLogEntry."Attached Receipt Count" += 1;
+
+ // Add the category to the list if it is not already present and if it fits within the maximum length of the Categories field.
+ if (not CategoriesTruncated) and
+ (ExpenseReportLine."Expense Category" <> '') and
+ (not CategoryCodes.Contains(ExpenseReportLine."Expense Category"))
+ then begin
+ CategoryCodes.Add(ExpenseReportLine."Expense Category");
+ Categories.Add(ExpenseReportLine."Expense Category");
+ Categories.WriteTo(CandidateCategoriesText);
+ if StrLen(CandidateCategoriesText) > MaxStrLen(ExpenseActivityLogEntry.Categories) then begin
+ Categories.RemoveAt(Categories.Count() - 1);
+ Categories.Add('...');
+ Categories.WriteTo(CandidateCategoriesText);
+ while StrLen(CandidateCategoriesText) > MaxStrLen(ExpenseActivityLogEntry.Categories) do begin
+ Categories.RemoveAt(Categories.Count() - 2);
+ Categories.WriteTo(CandidateCategoriesText);
+ end;
+ CategoriesText := CandidateCategoriesText;
+ CategoriesTruncated := true;
+ end;
+ CategoriesText := CandidateCategoriesText;
+ end;
+ until ExpenseReportLine.Next() = 0;
+
+ ExpenseActivityLogEntry.Categories :=
+ CopyStr(CategoriesText, 1, MaxStrLen(ExpenseActivityLogEntry.Categories));
+ end;
+}
diff --git a/src/Apps/W1/ExpenseAgent/app/src/ActivityLog/Enums/ExpenseActivityActorRole.Enum.al b/src/Apps/W1/ExpenseAgent/app/src/ActivityLog/Enums/ExpenseActivityActorRole.Enum.al
new file mode 100644
index 00000000000..70bb9c6435b
--- /dev/null
+++ b/src/Apps/W1/ExpenseAgent/app/src/ActivityLog/Enums/ExpenseActivityActorRole.Enum.al
@@ -0,0 +1,25 @@
+// ------------------------------------------------------------------------------------------------
+// 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;
+
+enum 6924 "Expense Activity Actor Role"
+{
+ Access = Internal;
+ Extensible = false;
+ Caption = 'Expense Activity Actor Role';
+
+ value(0; " ")
+ {
+ Caption = ' ', Locked = true;
+ }
+ value(1; Submitter)
+ {
+ Caption = 'Submitter';
+ }
+ value(2; Approver)
+ {
+ Caption = 'Approver';
+ }
+}
diff --git a/src/Apps/W1/ExpenseAgent/app/src/ActivityLog/Enums/ExpenseActivityEventType.Enum.al b/src/Apps/W1/ExpenseAgent/app/src/ActivityLog/Enums/ExpenseActivityEventType.Enum.al
new file mode 100644
index 00000000000..1da514c4cd3
--- /dev/null
+++ b/src/Apps/W1/ExpenseAgent/app/src/ActivityLog/Enums/ExpenseActivityEventType.Enum.al
@@ -0,0 +1,69 @@
+// ------------------------------------------------------------------------------------------------
+// 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;
+
+enum 6922 "Expense Activity Event Type"
+{
+ Access = Internal;
+ Extensible = false;
+ Caption = 'Expense Activity Event Type';
+
+ value(0; " ")
+ {
+ Caption = ' ', Locked = true;
+ }
+ value(1; Created)
+ {
+ Caption = 'Created';
+ }
+ value(2; ExpenseAddedToReport)
+ {
+ Caption = 'Expense added to report';
+ }
+ value(3; ExpenseRemovedFromReport)
+ {
+ Caption = 'Expense removed from report';
+ }
+ value(10; Submitted)
+ {
+ Caption = 'Submitted';
+ }
+ value(11; Resubmitted)
+ {
+ Caption = 'Resubmitted';
+ }
+ value(12; Recalled)
+ {
+ Caption = 'Recalled';
+ }
+ value(20; Approved)
+ {
+ Caption = 'Approved';
+ }
+ value(21; Rejected)
+ {
+ Caption = 'Rejected';
+ }
+ value(22; ReopenedByApprover)
+ {
+ Caption = 'Reopened by approver';
+ }
+ value(30; CommentAdded)
+ {
+ Caption = 'Comment added';
+ }
+ value(40; PolicyEvaluated)
+ {
+ Caption = 'Policy evaluated';
+ }
+ value(50; Edited)
+ {
+ Caption = 'Edited';
+ }
+ value(100; Posted)
+ {
+ Caption = 'Posted';
+ }
+}
diff --git a/src/Apps/W1/ExpenseAgent/app/src/ActivityLog/Enums/ExpenseActivityInitiator.Enum.al b/src/Apps/W1/ExpenseAgent/app/src/ActivityLog/Enums/ExpenseActivityInitiator.Enum.al
new file mode 100644
index 00000000000..128313f36f1
--- /dev/null
+++ b/src/Apps/W1/ExpenseAgent/app/src/ActivityLog/Enums/ExpenseActivityInitiator.Enum.al
@@ -0,0 +1,25 @@
+// ------------------------------------------------------------------------------------------------
+// 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;
+
+enum 6923 "Expense Activity Initiator"
+{
+ Access = Internal;
+ Extensible = false;
+ Caption = 'Expense Activity Initiator';
+
+ value(0; " ")
+ {
+ Caption = ' ', Locked = true;
+ }
+ value(1; User)
+ {
+ Caption = 'User';
+ }
+ value(2; Agent)
+ {
+ Caption = 'Agent';
+ }
+}
diff --git a/src/Apps/W1/ExpenseAgent/app/src/ActivityLog/Pages/ExpenseActivityLogFactBox.Page.al b/src/Apps/W1/ExpenseAgent/app/src/ActivityLog/Pages/ExpenseActivityLogFactBox.Page.al
new file mode 100644
index 00000000000..eac5c064307
--- /dev/null
+++ b/src/Apps/W1/ExpenseAgent/app/src/ActivityLog/Pages/ExpenseActivityLogFactBox.Page.al
@@ -0,0 +1,45 @@
+// ------------------------------------------------------------------------------------------------
+// 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;
+
+page 7123 "Expense Activity Log FactBox"
+{
+ PageType = ListPart;
+ SourceTable = "Expense Activity Log Entry";
+ SourceTableView = sorting("Source Table ID", "Source Record System ID", "Occurred At", "Entry No.") order(descending);
+ Caption = 'Activity Log';
+ ApplicationArea = Basic, Suite;
+ Editable = false;
+ InsertAllowed = false;
+ ModifyAllowed = false;
+ DeleteAllowed = false;
+ LinksAllowed = false;
+
+ layout
+ {
+ area(Content)
+ {
+ repeater(Entries)
+ {
+ field("Event Type"; Rec."Event Type")
+ {
+ ToolTip = 'Specifies what happened to the expense report.';
+ }
+ field("Occurred At"; Rec."Occurred At")
+ {
+ ToolTip = 'Specifies when the activity occurred.';
+ }
+ field("Actor Display Name"; Rec."Actor Display Name")
+ {
+ ToolTip = 'Specifies the person who performed the activity, when there was one.';
+ }
+ field(Comment; Rec.Comment)
+ {
+ ToolTip = 'Specifies the message recorded with the activity.';
+ }
+ }
+ }
+ }
+}
diff --git a/src/Apps/W1/ExpenseAgent/app/src/ActivityLog/Tables/ExpenseActivityLogEntry.Table.al b/src/Apps/W1/ExpenseAgent/app/src/ActivityLog/Tables/ExpenseActivityLogEntry.Table.al
new file mode 100644
index 00000000000..bac62d76be3
--- /dev/null
+++ b/src/Apps/W1/ExpenseAgent/app/src/ActivityLog/Tables/ExpenseActivityLogEntry.Table.al
@@ -0,0 +1,228 @@
+// ------------------------------------------------------------------------------------------------
+// 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 System.Security.AccessControl;
+
+table 7100 "Expense Activity Log Entry"
+{
+ Access = Internal;
+ Caption = 'Expense Activity Log Entry';
+ DataClassification = CustomerContent;
+ ReplicateData = false;
+
+ fields
+ {
+ field(1; "Entry No."; BigInteger)
+ {
+ Caption = 'Entry No.';
+ DataClassification = SystemMetadata;
+ AutoIncrement = true;
+ }
+ field(2; "Source Table ID"; Integer)
+ {
+ Caption = 'Source Table ID';
+ DataClassification = SystemMetadata;
+ ToolTip = 'Specifies the table containing the source document that currently owns the activity entry.';
+ }
+ field(3; "Source Record System ID"; Guid)
+ {
+ Caption = 'Source Record System ID';
+ DataClassification = SystemMetadata;
+ TableRelation = if ("Source Table ID" = const(Database::"Expense Report Header")) "Expense Report Header".SystemId
+ else
+ if ("Source Table ID" = const(Database::"Posted Expense Report Header")) "Posted Expense Report Header".SystemId;
+ ToolTip = 'Specifies the immutable SystemId of the source document that currently owns the activity entry.';
+ }
+ field(4; "Subject Table ID"; Integer)
+ {
+ Caption = 'Subject Table ID';
+ DataClassification = SystemMetadata;
+ ToolTip = 'Specifies the table containing the logical subject described by the activity entry.';
+ }
+ field(5; "Subject System ID"; Guid)
+ {
+ Caption = 'Subject System ID';
+ DataClassification = SystemMetadata;
+ ToolTip = 'Specifies the stable SystemId of the logical subject described by the activity entry.';
+ }
+ field(6; "Document No."; Code[20])
+ {
+ Caption = 'Document No.';
+ DataClassification = CustomerContent;
+ }
+ field(7; "Document Description"; Text[100])
+ {
+ Caption = 'Document Description';
+ DataClassification = CustomerContent;
+ }
+ field(8; "Event Type"; Enum "Expense Activity Event Type")
+ {
+ Caption = 'Event Type';
+ DataClassification = SystemMetadata;
+ ToolTip = 'Specifies what happened to the expense report.';
+ }
+ field(9; "Occurred At"; DateTime)
+ {
+ Caption = 'Occurred At';
+ DataClassification = SystemMetadata;
+ ToolTip = 'Specifies when the activity occurred.';
+ }
+ field(10; "Initiated By"; Enum "Expense Activity Initiator")
+ {
+ Caption = 'Initiated By';
+ DataClassification = SystemMetadata;
+ ToolTip = 'Specifies whether the activity originated from a person or the autonomous Expense Agent.';
+ }
+ field(11; "Actor Role"; Enum "Expense Activity Actor Role")
+ {
+ Caption = 'Actor Role';
+ DataClassification = SystemMetadata;
+ ToolTip = 'Specifies the business capacity in which the actor performed the activity.';
+ }
+ field(12; "Actor Table ID"; Integer)
+ {
+ Caption = 'Actor Table ID';
+ DataClassification = SystemMetadata;
+ ToolTip = 'Specifies the table containing the record that represents the actor.';
+ }
+ field(13; "Actor Record System ID"; Guid)
+ {
+ Caption = 'Actor Record System ID';
+ DataClassification = EndUserPseudonymousIdentifiers;
+ TableRelation = if ("Actor Table ID" = const(Database::"Expense User")) "Expense User".SystemId
+ else
+ if ("Actor Table ID" = const(Database::User)) User.SystemId;
+ ToolTip = 'Specifies the immutable SystemId of the record that represents the actor.';
+ }
+ field(14; "Actor Display Name"; Text[100])
+ {
+ Caption = 'Actor Display Name';
+ DataClassification = EndUserIdentifiableInformation;
+ ToolTip = 'Specifies the actor name captured when the activity occurred.';
+ }
+ field(15; "Amount (LCY)"; Decimal)
+ {
+ Caption = 'Amount (LCY)';
+ DataClassification = AccountData;
+ }
+ field(16; "Non-Refundable Amount (LCY)"; Decimal)
+ {
+ Caption = 'Non-Refundable Amount (LCY)';
+ DataClassification = AccountData;
+ }
+ field(17; "Reimbursable Amount"; Decimal)
+ {
+ Caption = 'Reimbursable Amount';
+ DataClassification = AccountData;
+ }
+ field(18; "Reimbursable Amount (LCY)"; Decimal)
+ {
+ Caption = 'Reimbursable Amount (LCY)';
+ DataClassification = AccountData;
+ }
+ field(19; "Refundable Amount"; Decimal)
+ {
+ Caption = 'Refundable Amount';
+ DataClassification = AccountData;
+ }
+ field(20; "Refundable Amount (LCY)"; Decimal)
+ {
+ Caption = 'Refundable Amount (LCY)';
+ DataClassification = AccountData;
+ }
+ field(21; "Reimbursement Currency Code"; Code[10])
+ {
+ Caption = 'Reimbursement Currency Code';
+ DataClassification = AccountData;
+ }
+ field(22; "Reimbursement Currency Factor"; Decimal)
+ {
+ Caption = 'Reimbursement Currency Factor';
+ DataClassification = AccountData;
+ DecimalPlaces = 0 : 15;
+ }
+ field(50; Comment; Text[2048])
+ {
+ Caption = 'Comment';
+ DataClassification = CustomerContent;
+ ToolTip = 'Specifies the message recorded with the activity.';
+ }
+ field(51; Categories; Text[2048])
+ {
+ Caption = 'Categories';
+ DataClassification = CustomerContent;
+ }
+ field(52; "Attached Receipt Count"; Integer)
+ {
+ Caption = 'Attached Receipt Count';
+ DataClassification = SystemMetadata;
+ }
+ field(53; "Expense Count"; Integer)
+ {
+ Caption = 'Expense Count';
+ DataClassification = SystemMetadata;
+ }
+ field(100; "History Actor Table ID Filter"; Integer)
+ {
+ Caption = 'History Actor Table ID Filter';
+ FieldClass = FlowFilter;
+ }
+ field(101; "History Actor System ID Filter"; Guid)
+ {
+ Caption = 'History Actor System ID Filter';
+ FieldClass = FlowFilter;
+ }
+ field(102; "History Actor Role Filter"; Enum "Expense Activity Actor Role")
+ {
+ Caption = 'History Actor Role Filter';
+ FieldClass = FlowFilter;
+ }
+ field(103; "History Subject Match"; Boolean)
+ {
+ Caption = 'History Subject Match';
+ FieldClass = FlowField;
+ CalcFormula = exist("Expense Activity Log Entry" where(
+ "Subject Table ID" = field("Subject Table ID"),
+ "Subject System ID" = field("Subject System ID"),
+ "Actor Table ID" = field("History Actor Table ID Filter"),
+ "Actor Record System ID" = field("History Actor System ID Filter"),
+ "Actor Role" = field("History Actor Role Filter")));
+ }
+ }
+
+ keys
+ {
+ key(PK; "Entry No.")
+ {
+ Clustered = true;
+ }
+ key(Source; "Source Table ID", "Source Record System ID", "Occurred At", "Entry No.")
+ {
+ }
+ key(Subject; "Subject Table ID", "Subject System ID", "Occurred At", "Entry No.")
+ {
+ }
+ key(Actor; "Actor Table ID", "Actor Record System ID", "Actor Role", "Subject Table ID", "Subject System ID", "Occurred At", "Entry No.")
+ {
+ }
+ key(Occurred; "Occurred At", "Entry No.")
+ {
+ }
+ }
+
+ trigger OnModify()
+ begin
+ Error(CannotModifyErr);
+ end;
+
+ trigger OnRename()
+ begin
+ Error(CannotModifyErr);
+ end;
+
+ var
+ CannotModifyErr: Label 'Activity log entries cannot be modified.';
+}
diff --git a/src/Apps/W1/ExpenseAgent/app/src/Approval/Codeunits/ExpenseReportApprovalMgmt.Codeunit.al b/src/Apps/W1/ExpenseAgent/app/src/Approval/Codeunits/ExpenseReportApprovalMgmt.Codeunit.al
index 3d94ec53559..1614b486e78 100644
--- a/src/Apps/W1/ExpenseAgent/app/src/Approval/Codeunits/ExpenseReportApprovalMgmt.Codeunit.al
+++ b/src/Apps/W1/ExpenseAgent/app/src/Approval/Codeunits/ExpenseReportApprovalMgmt.Codeunit.al
@@ -69,61 +69,98 @@ codeunit 6901 "Expense Report Approval Mgmt"
procedure Submit(var ExpenseReportHeader: Record "Expense Report Header")
var
ExpenseUser: Record "Expense User";
+ IsResubmission: Boolean;
begin
if ExpenseReportHeader.Status = ExpenseReportHeader.Status::"Pending Approval" then
exit;
+ IsResubmission := ExpenseReportHeader."Submission DateTime" <> 0DT;
ExpenseUser.Get(GetExpenseUserNo());
ExpenseReportHeader.TestApprovalStatus();
ExpenseReportHeader.UpdateApproverID();
SetApprovalStatusToPendingApprovalInExpenseReport(ExpenseReportHeader, ExpenseUser."No.", ExpenseUser."User Id For Approvals");
+ LogExpenseReportSubmission(ExpenseReportHeader, ExpenseUser."No.", IsResubmission);
end;
internal procedure Submit(var ExpenseReportHeader: Record "Expense Report Header"; SubmitterExpenseUserNo: Code[20])
var
ExpenseUser: Record "Expense User";
+ IsResubmission: Boolean;
begin
if ExpenseReportHeader.Status = ExpenseReportHeader.Status::"Pending Approval" then
exit;
+ IsResubmission := ExpenseReportHeader."Submission DateTime" <> 0DT;
ExpenseUser.Get(SubmitterExpenseUserNo);
ExpenseReportHeader.TestApprovalStatus();
ExpenseReportHeader.UpdateApproverID();
SetApprovalStatusToPendingApprovalInExpenseReport(ExpenseReportHeader, SubmitterExpenseUserNo, ExpenseUser."User Id For Approvals");
+ LogExpenseReportSubmission(ExpenseReportHeader, SubmitterExpenseUserNo, IsResubmission);
end;
procedure ReopenSubmitted(var ExpenseReportHeader: Record "Expense Report Header")
+ var
+ SubmitterExpenseUserNo: Code[20];
+ IsRecall: Boolean;
begin
if ExpenseReportHeader.Status = ExpenseReportHeader.Status::Open then
exit;
+ IsRecall := ExpenseReportHeader.Status = ExpenseReportHeader.Status::"Pending Approval";
+ if IsRecall then
+ SubmitterExpenseUserNo := GetExpenseUserNo();
+
ExpenseReportHeader.Status := ExpenseReportHeader.Status::Open;
ExpenseReportHeader.Modify(true);
+ if IsRecall then
+ LogExpenseReportRecalled(ExpenseReportHeader, SubmitterExpenseUserNo);
end;
procedure ReopenApproved(var ExpenseReportHeader: Record "Expense Report Header")
+ var
+ ApproverExpenseUserNo: Code[20];
begin
if ExpenseReportHeader.Status = ExpenseReportHeader.Status::"Pending Approval" then
exit;
CheckApproverPermissions(ExpenseReportHeader);
+ ApproverExpenseUserNo := GetExpenseUserNo();
+ ReopenApprovedAfterAuthorization(ExpenseReportHeader, ApproverExpenseUserNo);
+ end;
+
+ local procedure ReopenApprovedAfterAuthorization(
+ var ExpenseReportHeader: Record "Expense Report Header";
+ ApproverExpenseUserNo: Code[20]
+ )
+ begin
ExpenseReportHeader.UpdateApproverID();
ExpenseReportHeader.Status := ExpenseReportHeader.Status::"Pending Approval";
ExpenseReportHeader.Modify(true);
+ LogExpenseReportEvent(
+ ExpenseReportHeader,
+ Enum::"Expense Activity Event Type"::ReopenedByApprover,
+ Enum::"Expense Activity Actor Role"::Approver,
+ ApproverExpenseUserNo,
+ '');
end;
procedure Reject(var ExpenseReportHeader: Record "Expense Report Header")
+ var
+ ApproverExpenseUserNo: Code[20];
begin
if ExpenseReportHeader.Status = ExpenseReportHeader.Status::Rejected then
exit;
ExpenseReportHeader.TestField(Status, ExpenseReportHeader.Status::"Pending Approval");
CheckApproverPermissions(ExpenseReportHeader);
- SetApprovalStatusInExpenseReport(ExpenseReportHeader, ExpenseReportHeader.Status::Rejected, GetExpenseUserNo(), CopyStr(UserId(), 1, 50));
+ ApproverExpenseUserNo := GetExpenseUserNo();
+
+ SetApprovalStatusInExpenseReport(ExpenseReportHeader, ExpenseReportHeader.Status::Rejected, ApproverExpenseUserNo, CopyStr(UserId(), 1, 50));
+ LogExpenseReportRejected(ExpenseReportHeader, ApproverExpenseUserNo, '');
end;
internal procedure Reject(var ExpenseReportHeader: Record "Expense Report Header"; ApproverExpenseUserNo: Code[20]; RejectReason: Text)
@@ -138,17 +175,22 @@ codeunit 6901 "Expense Report Approval Mgmt"
UpdateApproverComment(ExpenseReportHeader, RejectReason);
SetApprovalStatusInExpenseReport(ExpenseReportHeader, ExpenseReportHeader.Status::Rejected, ApproverExpenseUserNo, ExpenseUser."User Id For Approvals");
+ LogExpenseReportRejected(ExpenseReportHeader, ApproverExpenseUserNo, RejectReason);
end;
procedure Approve(var ExpenseReportHeader: Record "Expense Report Header")
+ var
+ ApproverExpenseUserNo: Code[20];
begin
if ExpenseReportHeader.Status = ExpenseReportHeader.Status::Approved then
exit;
ExpenseReportHeader.TestField(Status, ExpenseReportHeader.Status::"Pending Approval");
CheckApproverPermissions(ExpenseReportHeader);
+ ApproverExpenseUserNo := GetExpenseUserNo();
- SetApprovalStatusInExpenseReport(ExpenseReportHeader, ExpenseReportHeader.Status::Approved, GetExpenseUserNo(), CopyStr(UserId(), 1, 50));
+ SetApprovalStatusInExpenseReport(ExpenseReportHeader, ExpenseReportHeader.Status::Approved, ApproverExpenseUserNo, CopyStr(UserId(), 1, 50));
+ LogExpenseReportApproved(ExpenseReportHeader, ApproverExpenseUserNo);
end;
internal procedure Approve(var ExpenseReportHeader: Record "Expense Report Header"; ApproverExpenseUserNo: Code[20])
@@ -162,6 +204,7 @@ codeunit 6901 "Expense Report Approval Mgmt"
CheckApproverPermissions(ExpenseUser);
SetApprovalStatusInExpenseReport(ExpenseReportHeader, ExpenseReportHeader.Status::Approved, ApproverExpenseUserNo, ExpenseUser."User Id For Approvals");
+ LogExpenseReportApproved(ExpenseReportHeader, ApproverExpenseUserNo);
end;
local procedure SetApprovalStatusInExpenseReport(var ExpenseReportHeader: Record "Expense Report Header"; ExpenseReportStatus: Enum "Expense Report Status"; ApproverExpenseUserNo: Code[20]; ApproverUserId: Code[50])
@@ -191,6 +234,78 @@ codeunit 6901 "Expense Report Approval Mgmt"
ExpenseReportHeader.Modify(true);
end;
+ local procedure LogExpenseReportSubmission(ExpenseReportHeader: Record "Expense Report Header"; SubmitterExpenseUserNo: Code[20]; IsResubmission: Boolean)
+ var
+ ExpenseActivityLogMgt: Codeunit "Expense Activity Log Mgt.";
+ EventType: Enum "Expense Activity Event Type";
+ begin
+ // Start tracking with the earlier Created event, including reports first acted on after upgrade.
+ if not ExpenseActivityLogMgt.HasEntriesForSource(Database::"Expense Report Header", ExpenseReportHeader.SystemId) then
+ ExpenseActivityLogMgt.LogExpenseReportCreatedEvent(ExpenseReportHeader);
+
+ if IsResubmission then
+ EventType := EventType::Resubmitted
+ else
+ EventType := EventType::Submitted;
+
+ ExpenseActivityLogMgt.LogExpenseReportEvent(
+ ExpenseReportHeader,
+ EventType,
+ Enum::"Expense Activity Initiator"::User,
+ Enum::"Expense Activity Actor Role"::Submitter,
+ SubmitterExpenseUserNo,
+ '');
+ end;
+
+ local procedure LogExpenseReportEvent(
+ ExpenseReportHeader: Record "Expense Report Header";
+ EventType: Enum "Expense Activity Event Type";
+ ActorRole: Enum "Expense Activity Actor Role";
+ ActorExpenseUserNo: Code[20];
+ EventComment: Text
+ )
+ var
+ ExpenseActivityLogMgt: Codeunit "Expense Activity Log Mgt.";
+ begin
+ ExpenseActivityLogMgt.LogExpenseReportEvent(
+ ExpenseReportHeader,
+ EventType,
+ Enum::"Expense Activity Initiator"::User,
+ ActorRole,
+ ActorExpenseUserNo,
+ EventComment);
+ end;
+
+ local procedure LogExpenseReportApproved(ExpenseReportHeader: Record "Expense Report Header"; ApproverExpenseUserNo: Code[20])
+ begin
+ LogExpenseReportEvent(
+ ExpenseReportHeader,
+ Enum::"Expense Activity Event Type"::Approved,
+ Enum::"Expense Activity Actor Role"::Approver,
+ ApproverExpenseUserNo,
+ '');
+ end;
+
+ local procedure LogExpenseReportRejected(ExpenseReportHeader: Record "Expense Report Header"; ApproverExpenseUserNo: Code[20]; RejectReason: Text)
+ begin
+ LogExpenseReportEvent(
+ ExpenseReportHeader,
+ Enum::"Expense Activity Event Type"::Rejected,
+ Enum::"Expense Activity Actor Role"::Approver,
+ ApproverExpenseUserNo,
+ RejectReason);
+ end;
+
+ local procedure LogExpenseReportRecalled(ExpenseReportHeader: Record "Expense Report Header"; SubmitterExpenseUserNo: Code[20])
+ begin
+ LogExpenseReportEvent(
+ ExpenseReportHeader,
+ Enum::"Expense Activity Event Type"::Recalled,
+ Enum::"Expense Activity Actor Role"::Submitter,
+ SubmitterExpenseUserNo,
+ '');
+ end;
+
internal procedure NoExpenseLinesToProcess(ExpenseApprovalAction: Enum "Expense Approval Action")
begin
if not GuiAllowed() then
diff --git a/src/Apps/W1/ExpenseAgent/app/src/Approval/Pages/ManagerExpenseReport.Page.al b/src/Apps/W1/ExpenseAgent/app/src/Approval/Pages/ManagerExpenseReport.Page.al
index 1ff1b0a8bff..3283d846fcc 100644
--- a/src/Apps/W1/ExpenseAgent/app/src/Approval/Pages/ManagerExpenseReport.Page.al
+++ b/src/Apps/W1/ExpenseAgent/app/src/Approval/Pages/ManagerExpenseReport.Page.al
@@ -205,6 +205,14 @@ page 6980 "Manager Expense Report"
SubPageLink = "Expense Report No." = field("Document No."), "Report Line No." = field("Line No.");
Visible = Rec."No." <> '';
}
+ part(Activity; "Expense Activity Log FactBox")
+ {
+ ApplicationArea = Basic, Suite;
+ Caption = 'Activity Log';
+ SubPageLink = "Source Table ID" = const(Database::"Expense Report Header"),
+ "Source Record System ID" = field(SystemId);
+ Visible = Rec."No." <> '';
+ }
part("Expense Picture"; "Expense Picture")
{
ApplicationArea = Basic, Suite;
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 481d011a1b8..88168cd6b62 100644
--- a/src/Apps/W1/ExpenseAgent/app/src/Capabilities/ExpenseCapabilitiesProvider.Codeunit.al
+++ b/src/Apps/W1/ExpenseAgent/app/src/Capabilities/ExpenseCapabilitiesProvider.Codeunit.al
@@ -32,6 +32,8 @@ codeunit 6906 "Expense Capabilities Provider"
exit(IsConsolidatedProjectsEnabled());
Capability::VATSpecifications:
exit(IsVATSpecificationsEnabled());
+ Capability::ActivityLog:
+ exit(true);
end;
exit(false);
end;
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 7ccca2dc8cd..dfee086137e 100644
--- a/src/Apps/W1/ExpenseAgent/app/src/Capabilities/ExpenseCapability.Enum.al
+++ b/src/Apps/W1/ExpenseAgent/app/src/Capabilities/ExpenseCapability.Enum.al
@@ -54,4 +54,12 @@ enum 6984 "Expense Capability"
{
Caption = 'VAT Specifications', Locked = true;
}
+
+ ///
+ /// Expense activity history API is available.
+ ///
+ value(4; ActivityLog)
+ {
+ Caption = 'Activity Log', Locked = true;
+ }
}
diff --git a/src/Apps/W1/ExpenseAgent/app/src/ExpenseReport/Codeunits/ExpenseReportPost.Codeunit.al b/src/Apps/W1/ExpenseAgent/app/src/ExpenseReport/Codeunits/ExpenseReportPost.Codeunit.al
index 815d674c65d..b7b670641e1 100644
--- a/src/Apps/W1/ExpenseAgent/app/src/ExpenseReport/Codeunits/ExpenseReportPost.Codeunit.al
+++ b/src/Apps/W1/ExpenseAgent/app/src/ExpenseReport/Codeunits/ExpenseReportPost.Codeunit.al
@@ -119,15 +119,26 @@ codeunit 6987 "Expense Report-Post"
end;
local procedure CheckAndCreatePostedDocument(var ExpenseReportHeader: Record "Expense Report Header")
+ var
+ ExpenseActivityLogMgt: Codeunit "Expense Activity Log Mgt.";
begin
AmountToEmployee := 0;
ValidateExpenseReportForPosting(ExpenseReportHeader);
CreatePostedExpenseReport(ExpenseReportHeader);
+ if not PreviewMode then
+ if ExpenseActivityLogMgt.HasEntriesForSource(Database::"Expense Report Header", ExpenseReportHeader.SystemId) then
+ ExpenseActivityLogMgt.LogExpenseReportEventByBCUser(
+ ExpenseReportHeader,
+ Enum::"Expense Activity Event Type"::Posted,
+ Enum::"Expense Activity Actor Role"::" ",
+ '');
UpdateLastPostingNos(ExpenseReportHeader);
ProcessExpenseReportLines(ExpenseReportHeader);
InsertPstdExpReportHeaderVATSpecs(ExpenseReportHeader."No.", PostedExpenseReportHeader."No.");
if AmountToEmployee <> 0 then
PostEmployeeEntry(ExpenseReportHeader);
+ if not PreviewMode then
+ ExpenseActivityLogMgt.ReassignExpenseReportEntriesToPosted(ExpenseReportHeader, PostedExpenseReportHeader);
end;
local procedure ValidateExpenseReportForPosting(var ExpenseReportHeader: Record "Expense Report Header")
diff --git a/src/Apps/W1/ExpenseAgent/app/src/ExpenseReport/Pages/ExpenseReport.Page.al b/src/Apps/W1/ExpenseAgent/app/src/ExpenseReport/Pages/ExpenseReport.Page.al
index 5b47a10a5f4..a12d79258f7 100644
--- a/src/Apps/W1/ExpenseAgent/app/src/ExpenseReport/Pages/ExpenseReport.Page.al
+++ b/src/Apps/W1/ExpenseAgent/app/src/ExpenseReport/Pages/ExpenseReport.Page.al
@@ -240,6 +240,14 @@ page 6910 "Expense Report"
SubPageLink = "Expense Report No." = field("Document No."), "Report Line No." = field("Line No.");
Visible = Rec."No." <> '';
}
+ part(Activity; "Expense Activity Log FactBox")
+ {
+ ApplicationArea = Basic, Suite;
+ Caption = 'Activity Log';
+ SubPageLink = "Source Table ID" = const(Database::"Expense Report Header"),
+ "Source Record System ID" = field(SystemId);
+ Visible = Rec."No." <> '';
+ }
part("Expense Picture"; "Expense Picture")
{
ApplicationArea = Basic, Suite;
diff --git a/src/Apps/W1/ExpenseAgent/app/src/ExpenseReport/Pages/PostedExpenseReport.Page.al b/src/Apps/W1/ExpenseAgent/app/src/ExpenseReport/Pages/PostedExpenseReport.Page.al
index 389f4a52f38..38a12f7e12a 100644
--- a/src/Apps/W1/ExpenseAgent/app/src/ExpenseReport/Pages/PostedExpenseReport.Page.al
+++ b/src/Apps/W1/ExpenseAgent/app/src/ExpenseReport/Pages/PostedExpenseReport.Page.al
@@ -171,6 +171,14 @@ page 6998 "Posted Expense Report"
UpdatePropagation = Both;
SubPageLink = "Posted Expense Report No." = field("No.");
}
+ part(Activity; "Expense Activity Log FactBox")
+ {
+ ApplicationArea = Basic, Suite;
+ Caption = 'Activity Log';
+ SubPageLink = "Source Table ID" = const(Database::"Posted Expense Report Header"),
+ "Source Record System ID" = field(SystemId);
+ Visible = Rec."No." <> '';
+ }
part("Expense Picture"; "Expense Picture")
{
ApplicationArea = Basic, Suite;
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 014a686e6f5..023e1e8810c 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
@@ -587,7 +587,9 @@ table 6906 "Expense Report Header"
var
ExpenseReportLine: Record "Expense Report Line";
ExpenseReportCommentLine: Record "Expense Report Comment Line";
+ ExpenseActivityLogMgt: Codeunit "Expense Activity Log Mgt.";
begin
+ ExpenseActivityLogMgt.DeleteEntriesForSource(Database::"Expense Report Header", Rec.SystemId);
ExpenseReportCommentLine.DeleteComments(ExpenseReportCommentLine."Document Type"::"Expense Report", Rec."No.");
ExpenseReportLine.SetRange("Document No.", Rec."No.");
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 8c21b013bed..01cc9cdeeae 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,9 @@ table 6915 "Posted Expense Report Header"
{
Clustered = true;
}
+ key(ExpenseUser; "Expense User No.", "No.")
+ {
+ }
}
trigger OnInsert()
@@ -323,7 +326,9 @@ table 6915 "Posted Expense Report Header"
trigger OnDelete()
var
PostedExpenseReportLines: Record "Posted Expense Report Line";
+ ExpenseActivityLogMgt: Codeunit "Expense Activity Log Mgt.";
begin
+ ExpenseActivityLogMgt.DeleteEntriesForSource(Database::"Posted Expense Report Header", Rec.SystemId);
PostedExpenseReportLines.SetRange("Document No.", Rec."No.");
PostedExpenseReportLines.DeleteAll();
end;
diff --git a/src/Apps/W1/ExpenseAgent/app/src/Permissions/AgentUserPermissions/ExpenseAgentData.PermissionSet.al b/src/Apps/W1/ExpenseAgent/app/src/Permissions/AgentUserPermissions/ExpenseAgentData.PermissionSet.al
index 933ac08b4c5..cbb047d41ce 100644
--- a/src/Apps/W1/ExpenseAgent/app/src/Permissions/AgentUserPermissions/ExpenseAgentData.PermissionSet.al
+++ b/src/Apps/W1/ExpenseAgent/app/src/Permissions/AgentUserPermissions/ExpenseAgentData.PermissionSet.al
@@ -58,5 +58,6 @@ permissionset 6953 "Expense Agent - Data"
#endif
tabledata "Expense Agent Env. Consumption" = Ri,
tabledata "Tenant Feedback Setting" = R,
- tabledata "EA Outbox Email" = RIM;
+ tabledata "EA Outbox Email" = RIM,
+ tabledata "Expense Activity Log Entry" = 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 06eea4d3891..e7a6dd1d947 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
@@ -18,6 +18,7 @@ permissionset 6952 "Expense Agent - Objects"
page "Expense User Cons. API" = X,
page "Approver View API" = X,
page "Expense Capabilities API" = X,
+ page "Expense Activity Log API" = X,
page "Expense Agent Access Ctrl API" = X,
page "Expense Agent Setup API" = X,
page "Expense Approval Setup API" = X,
@@ -74,6 +75,7 @@ permissionset 6952 "Expense Agent - Objects"
table Expense = X,
table "Expense Approval Setup" = X,
table "Expense Capabilities Buffer" = X,
+ table "Expense Activity Log Entry" = X,
table "Expense Project Buf" = X,
#if not CLEAN29
#pragma warning disable AL0432 // Object is obsoleted
@@ -122,6 +124,7 @@ permissionset 6952 "Expense Agent - Objects"
codeunit "Expense Attachment Mgt." = X,
codeunit "Expense Auto Population" = X,
codeunit "Expense Capabilities Provider" = X,
+ codeunit "Expense Activity Log Mgt." = X,
codeunit "Expense Projects Builder" = X,
codeunit "Expense Consumption Handler" = X,
codeunit "Expense Currency" = 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
index faa7d10fd10..275a9c4ee7a 100644
--- a/src/Apps/W1/ExpenseAgent/app/src/Permissions/ExpenseManagementObjects.permissionset.al
+++ b/src/Apps/W1/ExpenseAgent/app/src/Permissions/ExpenseManagementObjects.permissionset.al
@@ -61,6 +61,7 @@ permissionset 6904 "Expense Management - Objects"
table "Expense Vendor" = X,
table "Expense Capabilities Buffer" = X,
table Traveler = X,
+ table "Expense Activity Log Entry" = X,
table "Expense Project Buf" = X,
page "EA Billing Overview" = X,
page "EA Scheduler Tasks" = X,
@@ -148,6 +149,7 @@ permissionset 6904 "Expense Management - Objects"
#pragma warning restore AL0432
#endif
page "Expense Report FactBox" = X,
+ page "Expense Activity Log FactBox" = X,
page "Expense Approval Setups Part" = X,
page "Expense Picture" = X,
page Travelers = X,
@@ -198,6 +200,7 @@ permissionset 6904 "Expense Management - Objects"
codeunit "Expense OAuth Client" = X,
codeunit "Expense API Currency Helper" = X,
codeunit "Expense Capabilities Provider" = X,
+ codeunit "Expense Activity Log Mgt." = X,
codeunit "Expense Projects Builder" = X,
codeunit "Import Expense User" = X,
report "Expense Report Cover Page" = X,
diff --git a/src/Apps/W1/ExpenseAgent/app/src/Permissions/ExpenseMgmtRead.PermissionSet.al b/src/Apps/W1/ExpenseAgent/app/src/Permissions/ExpenseMgmtRead.PermissionSet.al
index 36b5e3c8691..b52505ecc24 100644
--- a/src/Apps/W1/ExpenseAgent/app/src/Permissions/ExpenseMgmtRead.PermissionSet.al
+++ b/src/Apps/W1/ExpenseAgent/app/src/Permissions/ExpenseMgmtRead.PermissionSet.al
@@ -58,5 +58,6 @@ permissionset 6907 "Expense Mgmt. Read"
tabledata Traveler = R,
tabledata "Expense VAT Specification" = R,
tabledata "Expense Report Line VAT Spec." = R,
+ tabledata "Expense Activity Log Entry" = R,
tabledata "Posted Exp. Rep. Line VAT Spec" = R;
}
\ No newline at end of file
diff --git a/src/Apps/W1/ExpenseAgent/app/src/Setup/Codeunits/ExpenseAgentPrivacySubs.Codeunit.al b/src/Apps/W1/ExpenseAgent/app/src/Setup/Codeunits/ExpenseAgentPrivacySubs.Codeunit.al
index cd30483ed8a..ac57004ba1d 100644
--- a/src/Apps/W1/ExpenseAgent/app/src/Setup/Codeunits/ExpenseAgentPrivacySubs.Codeunit.al
+++ b/src/Apps/W1/ExpenseAgent/app/src/Setup/Codeunits/ExpenseAgentPrivacySubs.Codeunit.al
@@ -21,6 +21,7 @@ codeunit 6950 "Expense Agent Privacy Subs."
local procedure ClassifyExpenseAgent()
var
+ ExpenseActivityLogEntry: Record "Expense Activity Log Entry";
ExpenseReportLineVATSpec: Record "Expense Report Line VAT Spec.";
PostedExpRepLineVATSpec: Record "Posted Exp. Rep. Line VAT Spec";
begin
@@ -58,6 +59,11 @@ codeunit 6950 "Expense Agent Privacy Subs."
DataClassificationEvalData.SetTableFieldsToNormal(Database::"Expense Team");
DataClassificationEvalData.SetTableFieldsToNormal(Database::"Expense Approval Setup");
DataClassificationEvalData.SetTableFieldsToNormal(Database::"Expense Agent Access Control");
+ DataClassificationEvalData.SetTableFieldsToNormal(Database::"Expense Activity Log Entry");
+ DataClassificationMgt.SetFieldToPersonal(
+ Database::"Expense Activity Log Entry", ExpenseActivityLogEntry.FieldNo("Actor Record System ID"));
+ DataClassificationMgt.SetFieldToPersonal(
+ Database::"Expense Activity Log Entry", ExpenseActivityLogEntry.FieldNo("Actor Display Name"));
DataClassificationEvalData.SetTableFieldsToNormal(Database::"Expense Vendor");
#if not CLEAN29
#pragma warning disable AL0432 // Object is obsoleted
diff --git a/src/Apps/W1/ExpenseAgent/test/src/API/ExpenseActivityLogAPITest.Codeunit.al b/src/Apps/W1/ExpenseAgent/test/src/API/ExpenseActivityLogAPITest.Codeunit.al
new file mode 100644
index 00000000000..e7672c950a6
--- /dev/null
+++ b/src/Apps/W1/ExpenseAgent/test/src/API/ExpenseActivityLogAPITest.Codeunit.al
@@ -0,0 +1,699 @@
+// ------------------------------------------------------------------------------------------------
+// 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.HumanResources.Employee;
+
+codeunit 148343 "Expense Activity Log API Test"
+{
+ Subtype = Test;
+ TestType = IntegrationTest;
+ RequiredTestIsolation = Disabled;
+ TestPermissions = Disabled;
+
+ var
+ Assert: Codeunit Assert;
+ LibraryExpense: Codeunit "Library - Expense";
+ LibraryGraphMgt: Codeunit "Library - Graph Mgt";
+ LibraryTestInitialize: Codeunit "Library - Test Initialize";
+ APITestAuthHelper: Codeunit "Expense API Test Auth Helper";
+ IsInitialized: Boolean;
+ ServiceNameTok: Label 'expenseActivityLogEntries', Locked = true;
+ ExpenseReportsServiceNameTok: Label 'expenseReports', Locked = true;
+ ExpenseUsersServiceNameTok: Label 'expenseUsers', Locked = true;
+ TestDescriptionPrefixLbl: Label 'ACTIVITY API TEST ', Locked = true;
+ MethodNotAllowedResponseErr: Label 'Response code is 405', Locked = true;
+ BadRequestResponseErr: Label 'Response code is 400', Locked = true;
+ SubmitActionTok: Label 'Microsoft.NAV.releaseAndMarkPendingApprovalExpenseReport', Locked = true;
+ ApproveActionTok: Label 'Microsoft.NAV.approvedExpenseReport', Locked = true;
+ RejectAndReopenActionTok: Label 'Microsoft.NAV.rejectAndReopenExpenseReport', Locked = true;
+
+ [Test]
+ procedure ActivityLogEntryIsExposedThroughReadOnlyAPI()
+ var
+ ExpenseUser: Record "Expense User";
+ ExpenseReportHeader: Record "Expense Report Header";
+ ExpenseActivityLogEntry: Record "Expense Activity Log Entry";
+ ExpenseActivityLogMgt: Codeunit "Expense Activity Log Mgt.";
+ EntryNo: BigInteger;
+ ResponseText: Text;
+ TargetURL: Text;
+ begin
+ // [SCENARIO] The read-only activity-log API exposes contract and diagnostic fields.
+ // [GIVEN] A Submitted activity entry.
+ Initialize();
+ CreateTestExpenseUser(ExpenseUser);
+ CreateTestExpenseReport(ExpenseReportHeader, ExpenseUser."No.");
+ EntryNo := ExpenseActivityLogMgt.LogExpenseReportEvent(
+ ExpenseReportHeader,
+ Enum::"Expense Activity Event Type"::Submitted,
+ Enum::"Expense Activity Initiator"::User,
+ Enum::"Expense Activity Actor Role"::Submitter,
+ ExpenseUser."No.",
+ 'Submitted for approval');
+ ExpenseActivityLogEntry.Get(EntryNo);
+ ExpenseActivityLogEntry.SetRange("Source Table ID", Database::"Expense Report Header");
+ ExpenseActivityLogEntry.SetRange("Source Record System ID", ExpenseReportHeader.SystemId);
+ Assert.RecordCount(ExpenseActivityLogEntry, 1);
+ Commit();
+
+ // [WHEN] The report-scoped activity-log collection is requested.
+ TargetURL := LibraryGraphMgt.CreateTargetURLWithSubpage(
+ Format(ExpenseReportHeader.SystemId),
+ Page::"Expense Reports API",
+ ExpenseReportsServiceNameTok,
+ ServiceNameTok);
+ LibraryGraphMgt.GetFromWebServiceAndCheckResponseCode(ResponseText, TargetURL, 200);
+ ResponseText := LowerCase(ResponseText);
+
+ // [THEN] The response contains stable identity, event, currency, and comment values.
+ Assert.AreNotEqual(
+ 0,
+ StrPos(ResponseText, LowerCase(LibraryGraphMgt.StripBrackets(Format(ExpenseActivityLogEntry.SystemId)))),
+ 'Response must contain the activity entry ID. Response: ' + ResponseText);
+ Assert.AreNotEqual(0, StrPos(ResponseText, '"entrynumber"'), 'Response must contain the internal entry number.');
+ Assert.AreNotEqual(0, StrPos(ResponseText, LowerCase(LibraryGraphMgt.StripBrackets(Format(ExpenseReportHeader.SystemId)))), 'Response must contain the stable subject ID.');
+ Assert.AreNotEqual(0, StrPos(ResponseText, '"actortableid"'), 'Response must identify the actor record table.');
+ Assert.AreNotEqual(0, StrPos(ResponseText, '"actorid"'), 'Response must identify the actor record.');
+ Assert.AreNotEqual(0, StrPos(ResponseText, '"eventtype":"submitted"'), 'Response must contain the stable submitted enum member name.');
+ Assert.AreNotEqual(0, StrPos(ResponseText, '"currencylcy"'), 'Response must identify the LCY used by LCY amount fields.');
+ Assert.AreNotEqual(0, StrPos(ResponseText, '"expensecount"'), 'Response must contain the expense count snapshot.');
+ Assert.AreNotEqual(0, StrPos(ResponseText, '"attachedreceiptcount"'), 'Response must contain the attached receipt count snapshot.');
+ Assert.AreNotEqual(0, StrPos(ResponseText, '"comment":"submitted for approval"'), 'Response must contain the event comment.');
+ CompleteTest();
+ end;
+
+ [Test]
+ procedure ActivityLogAPIRejectsWrites()
+ var
+ ExpenseUser: Record "Expense User";
+ ExpenseReportHeader: Record "Expense Report Header";
+ ExpenseActivityLogEntry: Record "Expense Activity Log Entry";
+ ExpenseActivityLogMgt: Codeunit "Expense Activity Log Mgt.";
+ EntryNo: BigInteger;
+ ResponseText: Text;
+ CollectionURL: Text;
+ EntryURL: Text;
+ begin
+ // [SCENARIO] The activity-log API cannot be used to mutate audit entries.
+ // [GIVEN] An existing activity entry and its collection and entity URLs.
+ Initialize();
+ CreateTestExpenseUser(ExpenseUser);
+ CreateTestExpenseReport(ExpenseReportHeader, ExpenseUser."No.");
+ EntryNo := ExpenseActivityLogMgt.LogExpenseReportEvent(
+ ExpenseReportHeader,
+ Enum::"Expense Activity Event Type"::Created,
+ Enum::"Expense Activity Initiator"::User,
+ Enum::"Expense Activity Actor Role"::Submitter,
+ ExpenseUser."No.",
+ '');
+ ExpenseActivityLogEntry.Get(EntryNo);
+ Commit();
+
+ CollectionURL := LibraryGraphMgt.CreateTargetURLWithSubpage(
+ Format(ExpenseReportHeader.SystemId),
+ Page::"Expense Reports API",
+ ExpenseReportsServiceNameTok,
+ ServiceNameTok);
+ EntryURL :=
+ CollectionURL + '(' +
+ LibraryGraphMgt.StripBrackets(Format(ExpenseActivityLogEntry.SystemId)) + ')';
+
+ // [WHEN] A POST is attempted.
+ // [THEN] The API rejects it with Method Not Allowed.
+ asserterror LibraryGraphMgt.PostToWebServiceAndCheckResponseCode(CollectionURL, '{}', ResponseText, 405);
+ Assert.ExpectedError(MethodNotAllowedResponseErr);
+
+ // [WHEN] A PATCH is attempted.
+ // [THEN] The API rejects it with Method Not Allowed.
+ asserterror LibraryGraphMgt.PatchToWebServiceAndCheckResponseCode(EntryURL, '{"comment":"changed"}', ResponseText, 405);
+ Assert.ExpectedError(MethodNotAllowedResponseErr);
+
+ // [WHEN] A DELETE is attempted.
+ // [THEN] The API rejects it with Method Not Allowed.
+ asserterror LibraryGraphMgt.DeleteFromWebServiceAndCheckResponseCode(EntryURL, '', ResponseText, 400);
+ Assert.ExpectedError(BadRequestResponseErr);
+ CompleteTest();
+ end;
+
+ [Test]
+ procedure NonSnapshotActivityDoesNotExposeCurrencies()
+ var
+ ExpenseUser: Record "Expense User";
+ ExpenseReportHeader: Record "Expense Report Header";
+ ExpenseActivityLogEntry: Record "Expense Activity Log Entry";
+ ExpenseActivityLogMgt: Codeunit "Expense Activity Log Mgt.";
+ EntryNo: BigInteger;
+ ResponseText: Text;
+ TargetURL: Text;
+ begin
+ // [SCENARIO] Non-snapshot activity does not imply currencies for empty amount fields.
+ // [GIVEN] A Created activity entry.
+ Initialize();
+ CreateTestExpenseUser(ExpenseUser);
+ CreateTestExpenseReport(ExpenseReportHeader, ExpenseUser."No.");
+ EntryNo := ExpenseActivityLogMgt.LogExpenseReportEvent(
+ ExpenseReportHeader,
+ Enum::"Expense Activity Event Type"::Created,
+ Enum::"Expense Activity Initiator"::User,
+ Enum::"Expense Activity Actor Role"::Submitter,
+ ExpenseUser."No.",
+ '');
+ ExpenseActivityLogEntry.Get(EntryNo);
+ Commit();
+
+ // [WHEN] The activity entry is requested through the report scope.
+ TargetURL := LibraryGraphMgt.CreateTargetURLWithSubpage(
+ Format(ExpenseReportHeader.SystemId),
+ Page::"Expense Reports API",
+ ExpenseReportsServiceNameTok,
+ ServiceNameTok);
+ TargetURL += '(' + LibraryGraphMgt.StripBrackets(Format(ExpenseActivityLogEntry.SystemId)) + ')';
+ LibraryGraphMgt.GetFromWebServiceAndCheckResponseCode(ResponseText, TargetURL, 200);
+ ResponseText := LowerCase(ResponseText);
+
+ // [THEN] Both LCY and reimbursement currency fields are blank.
+ Assert.AreNotEqual(0, StrPos(ResponseText, '"currencylcy":""'), 'Non-snapshot activity must not expose an LCY currency.');
+ Assert.AreNotEqual(0, StrPos(ResponseText, '"reimbursementcurrencycode":""'), 'Non-snapshot activity must not expose a reimbursement currency.');
+ CompleteTest();
+ end;
+
+ [Test]
+ procedure UnscopedActivityAPIHidesEntries()
+ var
+ ExpenseUser: Record "Expense User";
+ ExpenseReportHeader: Record "Expense Report Header";
+ ExpenseActivityLogEntry: Record "Expense Activity Log Entry";
+ ExpenseActivityLogMgt: Codeunit "Expense Activity Log Mgt.";
+ EntryNo: BigInteger;
+ ResponseText: Text;
+ TargetURL: Text;
+ begin
+ // [SCENARIO] The activity API does not expose company-wide history without a document or user scope.
+ // [GIVEN] An existing activity entry.
+ Initialize();
+ CreateTestExpenseUser(ExpenseUser);
+ CreateTestExpenseReport(ExpenseReportHeader, ExpenseUser."No.");
+ EntryNo := ExpenseActivityLogMgt.LogExpenseReportEvent(
+ ExpenseReportHeader,
+ Enum::"Expense Activity Event Type"::Submitted,
+ Enum::"Expense Activity Initiator"::User,
+ Enum::"Expense Activity Actor Role"::Submitter,
+ ExpenseUser."No.",
+ '');
+ ExpenseActivityLogEntry.Get(EntryNo);
+ Commit();
+
+ // [WHEN] The root activity collection is requested without scope.
+ TargetURL := LibraryGraphMgt.CreateTargetURL('', Page::"Expense Activity Log API", ServiceNameTok);
+ asserterror LibraryGraphMgt.GetFromWebServiceAndCheckResponseCode(ResponseText, TargetURL, 400);
+
+ // [THEN] The endpoint explains that a source or Expense User scope is required.
+ Assert.ExpectedError('Activity log entries must be requested through an expense report, posted expense report, or expense user.');
+ CompleteTest();
+ end;
+
+ [Test]
+ procedure SubmitterHistoryReturnsCompleteTimelinesForSubmittedReports()
+ var
+ ExpenseUser: array[2] of Record "Expense User";
+ ExpenseReportHeader: array[2] of Record "Expense Report Header";
+ PostedExpenseReportHeader: Record "Posted Expense Report Header";
+ ExpenseActivityLogEntry: array[3] of Record "Expense Activity Log Entry";
+ ExpenseActivityLogMgt: Codeunit "Expense Activity Log Mgt.";
+ EntryNo: BigInteger;
+ ResponseText: Text;
+ TargetURL: Text;
+ begin
+ // [SCENARIO] Submitter history contains every activity entry on reports submitted by the user.
+ // [GIVEN] Two reports submitted by different users, with another user's approval on the first report.
+ Initialize();
+ CreateTestExpenseUser(ExpenseUser[1]);
+ CreateTestExpenseUser(ExpenseUser[2]);
+ CreateTestExpenseReport(ExpenseReportHeader[1], ExpenseUser[1]."No.");
+ CreateTestExpenseReport(ExpenseReportHeader[2], ExpenseUser[2]."No.");
+ EntryNo := ExpenseActivityLogMgt.LogExpenseReportEvent(
+ ExpenseReportHeader[1],
+ Enum::"Expense Activity Event Type"::Submitted,
+ Enum::"Expense Activity Initiator"::User,
+ Enum::"Expense Activity Actor Role"::Submitter,
+ ExpenseUser[1]."No.",
+ '');
+ ExpenseActivityLogEntry[1].Get(EntryNo);
+ EntryNo := ExpenseActivityLogMgt.LogExpenseReportEvent(
+ ExpenseReportHeader[1],
+ Enum::"Expense Activity Event Type"::Approved,
+ Enum::"Expense Activity Initiator"::User,
+ Enum::"Expense Activity Actor Role"::Approver,
+ ExpenseUser[2]."No.",
+ '');
+ ExpenseActivityLogEntry[2].Get(EntryNo);
+ EntryNo := ExpenseActivityLogMgt.LogExpenseReportEvent(
+ ExpenseReportHeader[2],
+ Enum::"Expense Activity Event Type"::Submitted,
+ Enum::"Expense Activity Initiator"::User,
+ Enum::"Expense Activity Actor Role"::Submitter,
+ ExpenseUser[2]."No.",
+ '');
+ ExpenseActivityLogEntry[3].Get(EntryNo);
+ PostedExpenseReportHeader.Init();
+ PostedExpenseReportHeader."No." := CopyStr(Format(CreateGuid()), 1, MaxStrLen(PostedExpenseReportHeader."No."));
+ PostedExpenseReportHeader.Description := ExpenseReportHeader[1].Description;
+ PostedExpenseReportHeader.Insert();
+ ExpenseActivityLogMgt.ReassignExpenseReportEntriesToPosted(ExpenseReportHeader[1], PostedExpenseReportHeader);
+ Commit();
+
+ // [WHEN] User-scoped activity is requested without a history role.
+ TargetURL := LibraryGraphMgt.CreateTargetURLWithSubpage(
+ Format(ExpenseUser[1].SystemId),
+ Page::"Expense Users API",
+ ExpenseUsersServiceNameTok,
+ ServiceNameTok);
+ asserterror LibraryGraphMgt.GetFromWebServiceAndCheckResponseCode(ResponseText, TargetURL, 400);
+
+ // [THEN] The endpoint explains that the role is required.
+ Assert.ExpectedError('The historyActorRole filter must be specified as Submitter or Approver.');
+
+ // [WHEN] Submitter history is requested through the first Expense User.
+ TargetURL += '?$filter=historyActorRole eq ''Submitter''';
+ LibraryGraphMgt.GetFromWebServiceAndCheckResponseCode(ResponseText, TargetURL, 200);
+ ResponseText := LowerCase(ResponseText);
+
+ // [THEN] Both posted-source entries on the first report are returned, while the other submitter's report is excluded.
+ Assert.AreNotEqual(0, StrPos(ResponseText, LowerCase(LibraryGraphMgt.StripBrackets(Format(ExpenseActivityLogEntry[1].SystemId)))), 'Submitter history must contain the submitted entry.');
+ Assert.AreNotEqual(0, StrPos(ResponseText, LowerCase(LibraryGraphMgt.StripBrackets(Format(ExpenseActivityLogEntry[2].SystemId)))), 'Submitter history must contain the approver entry on the same report.');
+ Assert.AreEqual(0, StrPos(ResponseText, LowerCase(LibraryGraphMgt.StripBrackets(Format(ExpenseActivityLogEntry[3].SystemId)))), 'Submitter history must exclude another submitter''s report.');
+ CompleteTest();
+ end;
+
+ [Test]
+ procedure ApproverHistoryReturnsCompleteTimelinesForActedOnReports()
+ var
+ ExpenseUser: array[3] of Record "Expense User";
+ ExpenseReportHeader: array[2] of Record "Expense Report Header";
+ ExpenseActivityLogEntry: array[4] of Record "Expense Activity Log Entry";
+ ExpenseActivityLogMgt: Codeunit "Expense Activity Log Mgt.";
+ EntryNo: BigInteger;
+ ResponseText: Text;
+ TargetURL: Text;
+ begin
+ // [SCENARIO] Approver history contains every activity entry on reports acted on by the approver.
+ // [GIVEN] Two submitted reports approved by different Expense Users.
+ Initialize();
+ CreateTestExpenseUser(ExpenseUser[1]);
+ CreateTestExpenseUser(ExpenseUser[2]);
+ CreateTestExpenseUser(ExpenseUser[3]);
+ CreateTestExpenseReport(ExpenseReportHeader[1], ExpenseUser[1]."No.");
+ CreateTestExpenseReport(ExpenseReportHeader[2], ExpenseUser[1]."No.");
+ EntryNo := ExpenseActivityLogMgt.LogExpenseReportEvent(
+ ExpenseReportHeader[1],
+ Enum::"Expense Activity Event Type"::Submitted,
+ Enum::"Expense Activity Initiator"::User,
+ Enum::"Expense Activity Actor Role"::Submitter,
+ ExpenseUser[1]."No.",
+ '');
+ ExpenseActivityLogEntry[1].Get(EntryNo);
+ EntryNo := ExpenseActivityLogMgt.LogExpenseReportEvent(
+ ExpenseReportHeader[1],
+ Enum::"Expense Activity Event Type"::Approved,
+ Enum::"Expense Activity Initiator"::User,
+ Enum::"Expense Activity Actor Role"::Approver,
+ ExpenseUser[2]."No.",
+ '');
+ ExpenseActivityLogEntry[2].Get(EntryNo);
+ EntryNo := ExpenseActivityLogMgt.LogExpenseReportEvent(
+ ExpenseReportHeader[2],
+ Enum::"Expense Activity Event Type"::Submitted,
+ Enum::"Expense Activity Initiator"::User,
+ Enum::"Expense Activity Actor Role"::Submitter,
+ ExpenseUser[1]."No.",
+ '');
+ ExpenseActivityLogEntry[3].Get(EntryNo);
+ EntryNo := ExpenseActivityLogMgt.LogExpenseReportEvent(
+ ExpenseReportHeader[2],
+ Enum::"Expense Activity Event Type"::Approved,
+ Enum::"Expense Activity Initiator"::User,
+ Enum::"Expense Activity Actor Role"::Approver,
+ ExpenseUser[3]."No.",
+ '');
+ ExpenseActivityLogEntry[4].Get(EntryNo);
+ Commit();
+
+ // [WHEN] Approver history is requested through the second Expense User.
+ TargetURL := LibraryGraphMgt.CreateTargetURLWithSubpage(
+ Format(ExpenseUser[2].SystemId),
+ Page::"Expense Users API",
+ ExpenseUsersServiceNameTok,
+ ServiceNameTok);
+ TargetURL += '?$filter=historyActorRole eq ''Approver''';
+ LibraryGraphMgt.GetFromWebServiceAndCheckResponseCode(ResponseText, TargetURL, 200);
+ ResponseText := LowerCase(ResponseText);
+
+ // [THEN] The complete first-report timeline is returned and the other approver's report is excluded.
+ Assert.AreNotEqual(0, StrPos(ResponseText, LowerCase(LibraryGraphMgt.StripBrackets(Format(ExpenseActivityLogEntry[1].SystemId)))), 'Approver history must contain the submitter entry on the acted-on report.');
+ Assert.AreNotEqual(0, StrPos(ResponseText, LowerCase(LibraryGraphMgt.StripBrackets(Format(ExpenseActivityLogEntry[2].SystemId)))), 'Approver history must contain the approver entry.');
+ Assert.AreEqual(0, StrPos(ResponseText, LowerCase(LibraryGraphMgt.StripBrackets(Format(ExpenseActivityLogEntry[3].SystemId)))), 'Approver history must exclude a report acted on by another approver.');
+ Assert.AreEqual(0, StrPos(ResponseText, LowerCase(LibraryGraphMgt.StripBrackets(Format(ExpenseActivityLogEntry[4].SystemId)))), 'Approver history must exclude the other approver entry.');
+ CompleteTest();
+ end;
+
+ [Test]
+ procedure E2EActivityLogScenario()
+ var
+ SubmitterExpenseUser: Record "Expense User";
+ ApproverExpenseUser: Record "Expense User";
+ ExpenseCategory: Record "Expense Category";
+ ExpensePaymentMethod: Record "Expense Payment Method";
+ ExpenseReportHeader: Record "Expense Report Header";
+ PostedExpenseReportHeader: Record "Posted Expense Report Header";
+ SubjectSystemID: Guid;
+ RunToken: Code[8];
+ begin
+ // [SCENARIO] A complete approval round trip is exposed through active, posted, and user-history APIs.
+ Initialize();
+ RunToken := CreateRunToken();
+ CreateE2ESetup(
+ SubmitterExpenseUser, ApproverExpenseUser,
+ ExpenseCategory, ExpensePaymentMethod, RunToken);
+ CreateE2EReport(
+ ExpenseReportHeader, SubmitterExpenseUser,
+ ExpenseCategory, ExpensePaymentMethod, RunToken);
+ Commit();
+
+ // [WHEN] The report is submitted, rejected/reopened, resubmitted, and approved through API actions.
+ InvokeReportAction(
+ ExpenseReportHeader.SystemId, SubmitActionTok,
+ CreateActorRequestBody('submitterExpenseUserNo', SubmitterExpenseUser."No."));
+ ExpenseReportHeader.Get(ExpenseReportHeader."No.");
+ InvokeReportAction(
+ ExpenseReportHeader.SystemId, RejectAndReopenActionTok,
+ CreateRejectRequestBody(ApproverExpenseUser."No.", 'E2E send back ' + RunToken));
+ ExpenseReportHeader.Get(ExpenseReportHeader."No.");
+ InvokeReportAction(
+ ExpenseReportHeader.SystemId, SubmitActionTok,
+ CreateActorRequestBody('submitterExpenseUserNo', SubmitterExpenseUser."No."));
+ ExpenseReportHeader.Get(ExpenseReportHeader."No.");
+ InvokeReportAction(
+ ExpenseReportHeader.SystemId, ApproveActionTok,
+ CreateActorRequestBody('approverExpenseUserNo', ApproverExpenseUser."No."));
+ ExpenseReportHeader.Get(ExpenseReportHeader."No.");
+
+ // [THEN] The active report timeline contains the approval.
+ VerifyReportActivity(
+ ExpenseReportHeader.SystemId, Page::"Expense Reports API",
+ ExpenseReportsServiceNameTok, Enum::"Expense Activity Event Type"::Approved);
+
+ // [WHEN] The approved report is moved to a posted source fixture.
+ SubjectSystemID := ExpenseReportHeader.SystemId;
+ MoveReportToPostedSource(ExpenseReportHeader, PostedExpenseReportHeader);
+ Commit();
+
+ // [THEN] Posted and user-scoped APIs expose the complete timeline.
+ VerifyReportActivity(
+ PostedExpenseReportHeader.SystemId, Page::"Posted Expense Reports API",
+ 'postedExpenseReports', Enum::"Expense Activity Event Type"::Posted);
+ VerifyUserHistory(SubmitterExpenseUser.SystemId, 'Submitter', SubjectSystemID);
+ VerifyUserHistory(ApproverExpenseUser.SystemId, 'Approver', SubjectSystemID);
+ CompleteTest();
+ end;
+
+ local procedure CreateE2ESetup(
+ var SubmitterExpenseUser: Record "Expense User";
+ var ApproverExpenseUser: Record "Expense User";
+ var ExpenseCategory: Record "Expense Category";
+ var ExpensePaymentMethod: Record "Expense Payment Method";
+ RunToken: Code[8]
+ )
+ var
+ ExpenseApprovalSetup: Record "Expense Approval Setup";
+ begin
+ CreateTestExpenseUser(SubmitterExpenseUser);
+ SubmitterExpenseUser."User Id For Approvals" :=
+ CopyStr('SUBMITTER-' + RunToken, 1, MaxStrLen(SubmitterExpenseUser."User Id For Approvals"));
+ SubmitterExpenseUser.Modify();
+
+ CreateTestExpenseUser(ApproverExpenseUser);
+ ApproverExpenseUser."Can Approve" := true;
+ ApproverExpenseUser."User Id For Approvals" :=
+ CopyStr('APPROVER-' + RunToken, 1, MaxStrLen(ApproverExpenseUser."User Id For Approvals"));
+ ApproverExpenseUser.Modify();
+ LibraryExpense.CreateExpenseApprovalSetup(
+ ExpenseApprovalSetup, SubmitterExpenseUser."No.", ApproverExpenseUser."No.");
+
+ LibraryExpense.CreateExpenseCategory(
+ ExpenseCategory,
+ ExpenseCategory."Reimbursement Type"::"Employee Paid",
+ ExpenseCategory."Expense Detail Required"::" ");
+ ExpenseCategory.Description :=
+ CopyStr(TestDescriptionPrefixLbl + RunToken, 1, MaxStrLen(ExpenseCategory.Description));
+ ExpenseCategory.Modify();
+ LibraryExpense.FindExpensePaymentMethod(
+ ExpensePaymentMethod, ExpensePaymentMethod."Reimbursement Type"::"Employee Paid");
+ end;
+
+ local procedure CreateE2EReport(
+ var ExpenseReportHeader: Record "Expense Report Header";
+ SubmitterExpenseUser: Record "Expense User";
+ ExpenseCategory: Record "Expense Category";
+ ExpensePaymentMethod: Record "Expense Payment Method";
+ RunToken: Code[8]
+ )
+ var
+ ExpenseReportLine: Record "Expense Report Line";
+ begin
+ LibraryExpense.CreateExpenseReport(ExpenseReportHeader, SubmitterExpenseUser."No.", '', '');
+ ExpenseReportHeader.Description :=
+ CopyStr(TestDescriptionPrefixLbl + 'E2E ' + RunToken, 1, MaxStrLen(ExpenseReportHeader.Description));
+ ExpenseReportHeader.Modify();
+ LibraryExpense.CreateExpenseReportLine(
+ ExpenseReportLine,
+ ExpenseReportHeader,
+ SubmitterExpenseUser."No.",
+ ExpenseCategory.Code,
+ ExpensePaymentMethod.Code,
+ true,
+ '',
+ 100);
+ end;
+
+ local procedure CreateActorRequestBody(PropertyName: Text; ExpenseUserNo: Code[20]) RequestBody: JsonObject
+ begin
+ RequestBody.Add(PropertyName, ExpenseUserNo);
+ end;
+
+ local procedure CreateRejectRequestBody(ApproverExpenseUserNo: Code[20]; RejectReason: Text) RequestBody: JsonObject
+ begin
+ RequestBody.Add('approverExpenseUserNo', ApproverExpenseUserNo);
+ RequestBody.Add('rejectReason', RejectReason);
+ end;
+
+ local procedure InvokeReportAction(ReportSystemID: Guid; ActionName: Text; RequestBody: JsonObject)
+ var
+ ResponseText: Text;
+ RequestBodyText: Text;
+ TargetURL: Text;
+ begin
+ RequestBody.WriteTo(RequestBodyText);
+ TargetURL := LibraryGraphMgt.CreateTargetURLWithSubpage(
+ Format(ReportSystemID), Page::"Expense Reports API", ExpenseReportsServiceNameTok, ActionName);
+ LibraryGraphMgt.PostToWebServiceAndCheckResponseCode(TargetURL, RequestBodyText, ResponseText, 200);
+ end;
+
+ local procedure MoveReportToPostedSource(
+ var ExpenseReportHeader: Record "Expense Report Header";
+ var PostedExpenseReportHeader: Record "Posted Expense Report Header"
+ )
+ var
+ ExpenseActivityLogMgt: Codeunit "Expense Activity Log Mgt.";
+ begin
+ PostedExpenseReportHeader.Init();
+ PostedExpenseReportHeader.TransferFields(ExpenseReportHeader);
+ PostedExpenseReportHeader.Insert();
+ ExpenseActivityLogMgt.LogExpenseReportEventByBCUser(
+ ExpenseReportHeader,
+ Enum::"Expense Activity Event Type"::Posted,
+ Enum::"Expense Activity Actor Role"::" ",
+ '');
+ ExpenseActivityLogMgt.ReassignExpenseReportEntriesToPosted(
+ ExpenseReportHeader, PostedExpenseReportHeader);
+ ExpenseReportHeader.Delete(true);
+ end;
+
+ local procedure VerifyReportActivity(
+ ReportSystemID: Guid;
+ ParentPageID: Integer;
+ ParentServiceName: Text;
+ ExpectedEventType: Enum "Expense Activity Event Type"
+ )
+ var
+ ResponseText: Text;
+ TargetURL: Text;
+ begin
+ TargetURL := LibraryGraphMgt.CreateTargetURLWithSubpage(
+ Format(ReportSystemID), ParentPageID, ParentServiceName, ServiceNameTok);
+ LibraryGraphMgt.GetFromWebServiceAndCheckResponseCode(ResponseText, TargetURL, 200);
+ Assert.AreNotEqual(
+ 0,
+ StrPos(LowerCase(ResponseText), LowerCase(Format(ExpectedEventType))),
+ 'The report activity response does not contain the expected event type.');
+ end;
+
+ local procedure VerifyUserHistory(ExpenseUserSystemID: Guid; HistoryRole: Text; SubjectSystemID: Guid)
+ var
+ ResponseText: Text;
+ TargetURL: Text;
+ begin
+ TargetURL := LibraryGraphMgt.CreateTargetURLWithSubpage(
+ Format(ExpenseUserSystemID),
+ Page::"Expense Users API",
+ ExpenseUsersServiceNameTok,
+ ServiceNameTok);
+ TargetURL += '?$filter=historyActorRole eq ''' + HistoryRole + '''';
+ LibraryGraphMgt.GetFromWebServiceAndCheckResponseCode(ResponseText, TargetURL, 200);
+ Assert.AreNotEqual(
+ 0,
+ StrPos(
+ LowerCase(ResponseText),
+ LowerCase(LibraryGraphMgt.StripBrackets(Format(SubjectSystemID)))),
+ 'The user history response does not contain the expected subject.');
+ end;
+
+ local procedure CreateRunToken(): Code[8]
+ begin
+ exit(CopyStr(DelChr(Format(CreateGuid()), '=', '{}-'), 1, 8));
+ end;
+
+ local procedure Initialize()
+ var
+ ExpenseAgentSetup: Record "Expense Agent Setup";
+ begin
+ LibraryTestInitialize.OnTestInitialize(Codeunit::"Expense Activity Log API Test");
+ CleanupTestData();
+ if IsInitialized then
+ exit;
+
+ BindSubscription(APITestAuthHelper);
+ LibraryTestInitialize.OnBeforeTestSuiteInitialize(Codeunit::"Expense Activity Log API Test");
+ if not ExpenseAgentSetup.Get() then begin
+ ExpenseAgentSetup.Init();
+ ExpenseAgentSetup.Insert();
+ end;
+ ExpenseAgentSetup."Enable Agent" := true;
+ ExpenseAgentSetup."Enable Approval Workflow" := false;
+ ExpenseAgentSetup."Use Rules" := false;
+ ExpenseAgentSetup.Modify();
+ LibraryExpense.SetupNumberSeriesInExpenseMgmt();
+ LibraryExpense.InitializeExpenseSourceCode();
+ IsInitialized := true;
+ Commit();
+ LibraryTestInitialize.OnAfterTestSuiteInitialize(Codeunit::"Expense Activity Log API Test");
+ end;
+
+ local procedure CreateTestExpenseReport(var ExpenseReportHeader: Record "Expense Report Header"; ExpenseUserNo: Code[20])
+ begin
+ LibraryExpense.CreateExpenseReport(ExpenseReportHeader, ExpenseUserNo, '', '');
+ ExpenseReportHeader.Description :=
+ CopyStr(TestDescriptionPrefixLbl + Format(CreateGuid()), 1, MaxStrLen(ExpenseReportHeader.Description));
+ ExpenseReportHeader.Modify();
+ end;
+
+ local procedure CreateTestExpenseUser(var ExpenseUser: Record "Expense User")
+ begin
+ LibraryExpense.CreateExpenseUser(ExpenseUser);
+ ExpenseUser.Name :=
+ CopyStr(TestDescriptionPrefixLbl + Format(CreateGuid()), 1, MaxStrLen(ExpenseUser.Name));
+ ExpenseUser.Modify();
+ end;
+
+ local procedure CompleteTest()
+ begin
+ CleanupTestData();
+ Commit();
+ end;
+
+ local procedure CleanupTestData()
+ var
+ Expense: Record Expense;
+ ExpenseApprovalSetup: Record "Expense Approval Setup";
+ ExpenseCategory: Record "Expense Category";
+ ExpenseReportHeader: Record "Expense Report Header";
+ ExpenseSubcategory: Record "Expense Subcategory";
+ PostedExpenseReportHeader: Record "Posted Expense Report Header";
+ ExpenseActivityLogEntry: Record "Expense Activity Log Entry";
+ ExpenseUser: Record "Expense User";
+ Employee: Record Employee;
+ CategoryCodes: List of [Code[20]];
+ EmployeeNumbers: List of [Code[20]];
+ ExpenseUserNumbers: List of [Code[20]];
+ CategoryCode: Code[20];
+ EmployeeNo: Code[20];
+ ExpenseUserNo: Code[20];
+ begin
+ ExpenseUser.SetLoadFields("No.", "Employee No.");
+ ExpenseUser.SetFilter(Name, TestDescriptionPrefixLbl + '*');
+ if ExpenseUser.FindSet() then
+ repeat
+ ExpenseUserNumbers.Add(ExpenseUser."No.");
+ if (ExpenseUser."Employee No." <> '') and
+ (not EmployeeNumbers.Contains(ExpenseUser."Employee No."))
+ then
+ EmployeeNumbers.Add(ExpenseUser."Employee No.");
+ until ExpenseUser.Next() = 0;
+
+ ExpenseCategory.SetLoadFields(Code);
+ ExpenseCategory.SetFilter(Description, TestDescriptionPrefixLbl + '*');
+ if ExpenseCategory.FindSet() then
+ repeat
+ if not CategoryCodes.Contains(ExpenseCategory.Code) then
+ CategoryCodes.Add(ExpenseCategory.Code);
+ until ExpenseCategory.Next() = 0;
+
+ ExpenseActivityLogEntry.SetFilter("Document Description", TestDescriptionPrefixLbl + '*');
+ ExpenseActivityLogEntry.DeleteAll();
+
+ ExpenseReportHeader.SetFilter(Description, TestDescriptionPrefixLbl + '*');
+ ExpenseReportHeader.DeleteAll(true);
+
+ PostedExpenseReportHeader.SetFilter(Description, TestDescriptionPrefixLbl + '*');
+ PostedExpenseReportHeader.DeleteAll(true);
+
+ foreach ExpenseUserNo in ExpenseUserNumbers do begin
+ Expense.SetLoadFields("Expense Category", "Expense Report No.");
+ Expense.SetRange("Expense User No.", ExpenseUserNo);
+ if Expense.FindSet() then
+ repeat
+ if (Expense."Expense Category" <> '') and
+ (not CategoryCodes.Contains(Expense."Expense Category"))
+ then
+ CategoryCodes.Add(Expense."Expense Category");
+ until Expense.Next() = 0;
+ Expense.ModifyAll("Expense Report No.", '');
+ Expense.DeleteAll(true);
+
+ ExpenseApprovalSetup.SetRange("Expense User No.", ExpenseUserNo);
+ ExpenseApprovalSetup.DeleteAll();
+ ExpenseApprovalSetup.Reset();
+ ExpenseApprovalSetup.SetRange("Approver No.", ExpenseUserNo);
+ ExpenseApprovalSetup.DeleteAll();
+ ExpenseApprovalSetup.Reset();
+
+ if ExpenseUser.Get(ExpenseUserNo) then
+ ExpenseUser.Delete(true);
+ end;
+
+ foreach EmployeeNo in EmployeeNumbers do
+ if Employee.Get(EmployeeNo) then
+ Employee.Delete(true);
+
+ foreach CategoryCode in CategoryCodes do begin
+ ExpenseSubcategory.SetRange("Expense Category Code", CategoryCode);
+ ExpenseSubcategory.DeleteAll(true);
+ if ExpenseCategory.Get(CategoryCode) then
+ ExpenseCategory.Delete(true);
+ end;
+ end;
+
+}
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 b1b5e4d839b..2e7467a3e9e 100644
--- a/src/Apps/W1/ExpenseAgent/test/src/API/ExpenseCapabilitiesAPITest.Codeunit.al
+++ b/src/Apps/W1/ExpenseAgent/test/src/API/ExpenseCapabilitiesAPITest.Codeunit.al
@@ -10,19 +10,18 @@ codeunit 148318 "Expense Capabilities API Test"
{
Subtype = Test;
TestType = IntegrationTest;
+ RequiredTestIsolation = Disabled;
TestPermissions = Disabled;
var
Assert: Codeunit Assert;
+ LibraryExpenseAgent: Codeunit "Library - Expense Agent";
LibraryGraphMgt: Codeunit "Library - Graph Mgt";
LibraryTestInitialize: Codeunit "Library - Test Initialize";
APITestAuthHelper: Codeunit "Expense API Test Auth Helper";
IsInitialized: Boolean;
ServiceNameTok: Label 'expenseCapabilities', Locked = true;
- ProjectsCapabilityNameTok: Label '"capabilityname":"projects"', Locked = true;
- ConsolidatedCapabilityNameTok: Label '"capabilityname":"consolidatedprojects"', Locked = true;
- IsEnabledTrueTok: Label '"isenabled":true', Locked = true;
- IsEnabledFalseTok: Label '"isenabled":false', Locked = true;
+ ActivityLogCapabilityNameTok: Label 'activityLog', Locked = true;
[Test]
procedure CapabilitiesProjectsEnabledViaAPI()
@@ -34,6 +33,7 @@ codeunit 148318 "Expense Capabilities API Test"
// [SCENARIO] When Expense Agent Setup has "Enable Project Fields" = true,
// the capabilities API exposes a 'projects' row with isEnabled = true.
Initialize();
+ LibraryExpenseAgent.BackupExpenseAgentSetup();
// [GIVEN] Expense Agent Setup exists with Enable Project Fields = true.
if not ExpenseAgentSetup.Get() then begin
@@ -47,15 +47,32 @@ codeunit 148318 "Expense Capabilities API Test"
// [WHEN] The expenseCapabilities collection is fetched through the API.
TargetURL := LibraryGraphMgt.CreateTargetURL('', Page::"Expense Capabilities API", ServiceNameTok);
LibraryGraphMgt.GetFromWebServiceAndCheckResponseCode(ResponseText, TargetURL, 200);
- ResponseText := StripWhitespace(LowerCase(ResponseText));
-
- // [THEN] A 'projects' row is present with isEnabled = true.
- Assert.AreNotEqual(0, StrPos(ResponseText, ProjectsCapabilityNameTok),
- 'Response must contain a projects capability row.');
- Assert.AreNotEqual(0, StrPos(ResponseText, IsEnabledTrueTok),
- 'Response must contain at least one isEnabled=true value.');
- Assert.AreEqual(0, StrPos(ResponseText, IsEnabledFalseTok),
- 'Response must NOT contain any isEnabled=false value when Projects is the only capability and it is enabled.');
+
+ // [THEN] The Projects capability is enabled, regardless of other capability states.
+ Assert.IsTrue(
+ ResponseContainsCapabilityState(ResponseText, 'projects', true),
+ 'Response must contain an enabled projects capability row.');
+ LibraryExpenseAgent.RestoreExpenseAgentSetup();
+ Commit();
+ end;
+
+ [Test]
+ procedure ActivityLogCapabilityEnabledViaAPI()
+ var
+ TargetURL: Text;
+ ResponseText: Text;
+ begin
+ // [SCENARIO] The Activity Log capability is always advertised when the API is 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] ActivityLog is present and enabled.
+ Assert.IsTrue(
+ ResponseContainsCapabilityState(ResponseText, ActivityLogCapabilityNameTok, true),
+ 'Response must contain an enabled activityLog capability row.');
end;
[Test]
@@ -68,6 +85,7 @@ codeunit 148318 "Expense Capabilities API Test"
// [SCENARIO] When Expense Agent Setup has "Enable Project Fields" = false,
// the capabilities API exposes a 'projects' row with isEnabled = false.
Initialize();
+ LibraryExpenseAgent.BackupExpenseAgentSetup();
// [GIVEN] Expense Agent Setup exists with Enable Project Fields = false.
if not ExpenseAgentSetup.Get() then begin
@@ -81,13 +99,13 @@ codeunit 148318 "Expense Capabilities API Test"
// [WHEN] The expenseCapabilities collection is fetched through the API.
TargetURL := LibraryGraphMgt.CreateTargetURL('', Page::"Expense Capabilities API", ServiceNameTok);
LibraryGraphMgt.GetFromWebServiceAndCheckResponseCode(ResponseText, TargetURL, 200);
- ResponseText := StripWhitespace(LowerCase(ResponseText));
- // [THEN] The 'projects' row is present and isEnabled = false.
- Assert.AreNotEqual(0, StrPos(ResponseText, ProjectsCapabilityNameTok),
- 'Response must contain a projects capability row.');
- Assert.AreNotEqual(0, StrPos(ResponseText, IsEnabledFalseTok),
- 'Projects row must be reported as isEnabled = false when Enable Project Fields is false.');
+ // [THEN] The Projects capability is disabled.
+ Assert.IsTrue(
+ ResponseContainsCapabilityState(ResponseText, 'projects', false),
+ 'Response must contain a disabled projects capability row.');
+ LibraryExpenseAgent.RestoreExpenseAgentSetup();
+ Commit();
end;
[Test]
@@ -100,6 +118,7 @@ codeunit 148318 "Expense Capabilities API Test"
// [SCENARIO] The 'consolidatedAssignedProjects' capability is reported enabled when
// project fields are enabled (the web app uses it to detect the new endpoint).
Initialize();
+ LibraryExpenseAgent.BackupExpenseAgentSetup();
if not ExpenseAgentSetup.Get() then begin
ExpenseAgentSetup.Init();
@@ -111,17 +130,19 @@ codeunit 148318 "Expense Capabilities API Test"
TargetURL := LibraryGraphMgt.CreateTargetURL('', Page::"Expense Capabilities API", ServiceNameTok);
LibraryGraphMgt.GetFromWebServiceAndCheckResponseCode(ResponseText, TargetURL, 200);
- ResponseText := StripWhitespace(LowerCase(ResponseText));
- // [THEN] A 'consolidatedAssignedProjects' row is present and no isEnabled=false values exist.
- Assert.AreNotEqual(0, StrPos(ResponseText, ConsolidatedCapabilityNameTok),
- 'Response must contain a consolidatedAssignedProjects capability row.');
- Assert.AreEqual(0, StrPos(ResponseText, IsEnabledFalseTok),
- 'No capability must be reported disabled when project fields are enabled.');
+ // [THEN] Consolidated Projects is enabled, regardless of other capability states.
+ Assert.IsTrue(
+ ResponseContainsCapabilityState(ResponseText, 'consolidatedProjects', true),
+ 'Response must contain an enabled consolidatedProjects capability row.');
+ LibraryExpenseAgent.RestoreExpenseAgentSetup();
+ Commit();
end;
local procedure Initialize()
begin
+ LibraryExpenseAgent.RestoreExpenseAgentSetup();
+ Commit();
LibraryTestInitialize.OnTestInitialize(Codeunit::"Expense Capabilities API Test");
if IsInitialized then
exit;
@@ -133,15 +154,42 @@ codeunit 148318 "Expense Capabilities API Test"
LibraryTestInitialize.OnAfterTestSuiteInitialize(Codeunit::"Expense Capabilities API Test");
end;
- local procedure StripWhitespace(Source: Text): Text
+ local procedure ResponseContainsCapabilityState(
+ ResponseText: Text;
+ CapabilityName: Text;
+ ExpectedEnabled: Boolean
+ ): Boolean
var
- Result: Text;
+ RootObject: JsonObject;
+ CapabilityObject: JsonObject;
+ ValueArray: JsonArray;
+ CapabilityToken: JsonToken;
+ PropertyToken: JsonToken;
+ CapabilityIndex: Integer;
begin
- Result := Source;
- Result := DelChr(Result, '=', ' ');
- Result := DelChr(Result, '=', Format(10)); // LF
- Result := DelChr(Result, '=', Format(13)); // CR
- Result := DelChr(Result, '=', Format(9)); // TAB
- exit(Result);
+ RootObject.ReadFrom(ResponseText);
+ if not RootObject.Get('value', PropertyToken) then
+ exit(false);
+
+ ValueArray := PropertyToken.AsArray();
+ if ValueArray.Count() = 0 then
+ exit(false);
+ for CapabilityIndex := 0 to ValueArray.Count() - 1 do begin
+ Clear(CapabilityToken);
+ Clear(CapabilityObject);
+ ValueArray.Get(CapabilityIndex, CapabilityToken);
+ CapabilityObject := CapabilityToken.AsObject();
+ Clear(PropertyToken);
+ if CapabilityObject.Get('capabilityName', PropertyToken) then
+ if LowerCase(PropertyToken.AsValue().AsText()) = LowerCase(CapabilityName) then begin
+ Clear(PropertyToken);
+ if not CapabilityObject.Get('isEnabled', PropertyToken) then
+ exit(false);
+ exit(PropertyToken.AsValue().AsBoolean() = ExpectedEnabled);
+ end;
+ end;
+
+ exit(false);
end;
+
}
diff --git a/src/Apps/W1/ExpenseAgent/test/src/ActivityLog/ExpenseActivityLogTest.Codeunit.al b/src/Apps/W1/ExpenseAgent/test/src/ActivityLog/ExpenseActivityLogTest.Codeunit.al
new file mode 100644
index 00000000000..cd57be0b0ce
--- /dev/null
+++ b/src/Apps/W1/ExpenseAgent/test/src/ActivityLog/ExpenseActivityLogTest.Codeunit.al
@@ -0,0 +1,484 @@
+// ------------------------------------------------------------------------------------------------
+// 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 System.Security.AccessControl;
+
+codeunit 148342 "Expense Activity Log Test"
+{
+ Subtype = Test;
+ TestType = UnitTest;
+ TestPermissions = Disabled;
+
+ var
+ Assert: Codeunit Assert;
+ LibraryExpense: Codeunit "Library - Expense";
+ LibraryTestInitialize: Codeunit "Library - Test Initialize";
+ LibraryUtility: Codeunit "Library - Utility";
+ IsInitialized: Boolean;
+
+ [Test]
+ procedure OnlySnapshotEventsCaptureFinancialValues()
+ var
+ ExpenseUser: Record "Expense User";
+ ExpenseReportHeader: Record "Expense Report Header";
+ ExpenseReportLine: Record "Expense Report Line";
+ ExpenseActivityLogEntry: Record "Expense Activity Log Entry";
+ ExpenseActivityLogMgt: Codeunit "Expense Activity Log Mgt.";
+ EventType: Enum "Expense Activity Event Type";
+ EntryNo: BigInteger;
+ EventTypeIndex: Integer;
+ SnapshotEventCount: Integer;
+ EventTypeOrdinals: List of [Integer];
+ SnapshotExpected: Boolean;
+ begin
+ // [SCENARIO] Financial and content snapshots are stored only for snapshot event types.
+ // [GIVEN] A report with financial values, two categories, and one receipt-bearing line.
+ Initialize();
+ LibraryExpense.CreateExpenseUser(ExpenseUser);
+ LibraryExpense.CreateExpenseReport(ExpenseReportHeader, ExpenseUser."No.", '', '');
+ ExpenseReportHeader."Reimbursement Currency Code" := 'USD';
+ ExpenseReportHeader."Reimbursement Currency Factor" := 1.25;
+ ExpenseReportLine."Document No." := ExpenseReportHeader."No.";
+ ExpenseReportLine."Line No." := 10000;
+ ExpenseReportLine."Amount (LCY)" := 120;
+ ExpenseReportLine."Non-Refundable Amount (LCY)" := 20;
+ ExpenseReportLine."Reimbursable Amount" := 80;
+ ExpenseReportLine."Reimbursable Amount (LCY)" := 75;
+ ExpenseReportLine."Refundable Amount" := 25;
+ ExpenseReportLine."Refundable Amount (LCY)" := 25;
+ ExpenseReportLine."Expense Category" := 'MEALS';
+ ExpenseReportLine."Receipt Attached" := true;
+ ExpenseReportLine.Insert();
+ Clear(ExpenseReportLine);
+ ExpenseReportLine."Document No." := ExpenseReportHeader."No.";
+ ExpenseReportLine."Line No." := 20000;
+ ExpenseReportLine."Expense Category" := 'TRAVEL';
+ ExpenseReportLine.Insert();
+
+ // [WHEN] Every concrete activity event type is logged.
+ EventTypeOrdinals := EventType.Ordinals();
+ for EventTypeIndex := 1 to EventTypeOrdinals.Count() do begin
+ EventType := Enum::"Expense Activity Event Type".FromInteger(EventTypeOrdinals.Get(EventTypeIndex));
+ if EventType.AsInteger() <> 0 then begin
+ EntryNo := ExpenseActivityLogMgt.LogExpenseReportEvent(
+ ExpenseReportHeader,
+ EventType,
+ Enum::"Expense Activity Initiator"::User,
+ Enum::"Expense Activity Actor Role"::Submitter,
+ ExpenseUser."No.",
+ '');
+
+ ExpenseActivityLogEntry.Get(EntryNo);
+ SnapshotExpected := EventType in [EventType::Submitted, EventType::Resubmitted, EventType::Posted];
+
+ // [THEN] Common identity, actor, and event metadata are always persisted.
+ Assert.AreEqual(Database::"Expense Report Header", ExpenseActivityLogEntry."Source Table ID", 'Source table ID must identify the expense report.');
+ Assert.AreEqual(ExpenseReportHeader.SystemId, ExpenseActivityLogEntry."Source Record System ID", 'Source SystemId must identify the expense report.');
+ Assert.AreEqual(ExpenseReportHeader.SystemId, ExpenseActivityLogEntry."Subject System ID", 'Subject SystemId must remain the stable report identity.');
+ Assert.AreEqual(Database::"Expense User", ExpenseActivityLogEntry."Actor Table ID", 'Actor table ID must identify the Expense User table.');
+ Assert.AreEqual(ExpenseUser.SystemId, ExpenseActivityLogEntry."Actor Record System ID", 'Actor SystemId must identify the expense user.');
+ Assert.AreEqual(ExpenseUser.Name, ExpenseActivityLogEntry."Actor Display Name", 'Actor display name must be captured as a snapshot.');
+ Assert.AreEqual(EventType, ExpenseActivityLogEntry."Event Type", 'Event type must be persisted.');
+ Assert.AreEqual(Enum::"Expense Activity Initiator"::User, ExpenseActivityLogEntry."Initiated By", 'Initiator must be persisted.');
+ Assert.AreEqual(Enum::"Expense Activity Actor Role"::Submitter, ExpenseActivityLogEntry."Actor Role", 'Actor role must be persisted.');
+
+ // [THEN] Only Submitted, Resubmitted, and Posted contain financial and content snapshots.
+ if SnapshotExpected then begin
+ SnapshotEventCount += 1;
+ Assert.AreEqual(120, ExpenseActivityLogEntry."Amount (LCY)", 'Total amount in LCY must be persisted.');
+ Assert.AreEqual(20, ExpenseActivityLogEntry."Non-Refundable Amount (LCY)", 'Non-refundable amount in LCY must be persisted.');
+ Assert.AreEqual(80, ExpenseActivityLogEntry."Reimbursable Amount", 'Reimbursable amount must be persisted.');
+ Assert.AreEqual(75, ExpenseActivityLogEntry."Reimbursable Amount (LCY)", 'Reimbursable amount in LCY must be persisted.');
+ Assert.AreEqual(25, ExpenseActivityLogEntry."Refundable Amount", 'Refundable amount must be persisted.');
+ Assert.AreEqual(25, ExpenseActivityLogEntry."Refundable Amount (LCY)", 'Refundable amount in LCY must be persisted.');
+ Assert.AreEqual(2, ExpenseActivityLogEntry."Expense Count", 'Expense count must include every report line.');
+ Assert.AreEqual(1, ExpenseActivityLogEntry."Attached Receipt Count", 'Attached receipt count must include only report lines with an attached receipt.');
+ Assert.AreEqual('USD', ExpenseActivityLogEntry."Reimbursement Currency Code", 'Reimbursement currency code must be persisted.');
+ Assert.AreEqual(1.25, ExpenseActivityLogEntry."Reimbursement Currency Factor", 'Reimbursement currency factor must be persisted.');
+ Assert.AreNotEqual('', ExpenseActivityLogEntry.Categories, 'Snapshot events must persist categories.');
+ end else begin
+ Assert.AreEqual(0, ExpenseActivityLogEntry."Amount (LCY)", 'Non-snapshot events must not duplicate financial values.');
+ Assert.AreEqual(0, ExpenseActivityLogEntry."Non-Refundable Amount (LCY)", 'Non-snapshot events must not duplicate financial values.');
+ Assert.AreEqual(0, ExpenseActivityLogEntry."Reimbursable Amount", 'Non-snapshot events must not duplicate financial values.');
+ Assert.AreEqual(0, ExpenseActivityLogEntry."Reimbursable Amount (LCY)", 'Non-snapshot events must not duplicate financial values.');
+ Assert.AreEqual(0, ExpenseActivityLogEntry."Refundable Amount", 'Non-snapshot events must not duplicate financial values.');
+ Assert.AreEqual(0, ExpenseActivityLogEntry."Refundable Amount (LCY)", 'Non-snapshot events must not duplicate financial values.');
+ Assert.AreEqual(0, ExpenseActivityLogEntry."Expense Count", 'Non-snapshot events must not duplicate expense counts.');
+ Assert.AreEqual(0, ExpenseActivityLogEntry."Attached Receipt Count", 'Non-snapshot events must not duplicate attached receipt counts.');
+ Assert.AreEqual('', ExpenseActivityLogEntry."Reimbursement Currency Code", 'Non-snapshot events must not duplicate currency values.');
+ Assert.AreEqual(0, ExpenseActivityLogEntry."Reimbursement Currency Factor", 'Non-snapshot events must not duplicate currency values.');
+ Assert.AreEqual('', ExpenseActivityLogEntry.Categories, 'Non-snapshot events must not duplicate categories.');
+ end;
+ end;
+ end;
+
+ Assert.AreEqual(3, SnapshotEventCount, 'Exactly three activity event types must capture snapshots.');
+ end;
+
+ [Test]
+ procedure CreatedEventFallsBackToBCUserActor()
+ var
+ ExpenseUser: Record "Expense User";
+ ExpenseReportHeader: Record "Expense Report Header";
+ ExpenseActivityLogEntry: Record "Expense Activity Log Entry";
+ User: Record User;
+ ExpenseActivityLogMgt: Codeunit "Expense Activity Log Mgt.";
+ EmptyGuid: Guid;
+ EntryNo: BigInteger;
+ begin
+ // [SCENARIO] Retrospective creation identifies the direct BC user when no Expense User creator was stored.
+ // [GIVEN] A report with a platform creator but no Created By Expense User Id.
+ Initialize();
+ LibraryExpense.CreateExpenseUser(ExpenseUser);
+ LibraryExpense.CreateExpenseReport(ExpenseReportHeader, ExpenseUser."No.", '', '');
+ ExpenseReportHeader."Created By Exp. User Id" := EmptyGuid;
+ ExpenseReportHeader.Modify(false);
+ User.Get(ExpenseReportHeader.SystemCreatedBy);
+
+ // [WHEN] The retrospective Created entry is logged.
+ EntryNo := ExpenseActivityLogMgt.LogExpenseReportCreatedEvent(ExpenseReportHeader);
+
+ // [THEN] The actor points to the BC User record identified by SystemCreatedBy.
+ ExpenseActivityLogEntry.Get(EntryNo);
+ Assert.AreEqual(Database::User, ExpenseActivityLogEntry."Actor Table ID", 'Created activity must identify the BC User table.');
+ Assert.AreEqual(User.SystemId, ExpenseActivityLogEntry."Actor Record System ID", 'Created activity must identify the BC User record.');
+ Assert.AreEqual(ExpenseReportHeader.SystemCreatedAt, ExpenseActivityLogEntry."Occurred At", 'Created activity must use the source record creation timestamp.');
+ end;
+
+ [Test]
+ procedure ApprovalLifecycleLogsImportantEvents()
+ var
+ SubmitterExpenseUser: Record "Expense User";
+ ApproverExpenseUser: Record "Expense User";
+ ExpenseReportHeader: Record "Expense Report Header";
+ ExpenseActivityLogEntry: Record "Expense Activity Log Entry";
+ ExpenseReportApprovalMgt: Codeunit "Expense Report Approval Mgmt";
+ begin
+ // [SCENARIO] The approval lifecycle records creation, submission, rejection, resubmission, and approval.
+ // [GIVEN] A released expense report with a submitter and approver.
+ Initialize();
+ CreateApprovalScenario(SubmitterExpenseUser, ApproverExpenseUser, ExpenseReportHeader);
+
+ // [WHEN] The report is submitted, rejected, resubmitted, and approved.
+ ExpenseReportApprovalMgt.Submit(ExpenseReportHeader, SubmitterExpenseUser."No.");
+ ExpenseReportApprovalMgt.Reject(ExpenseReportHeader, ApproverExpenseUser."No.", 'Please explain the change.');
+ ExpenseReportApprovalMgt.Submit(ExpenseReportHeader, SubmitterExpenseUser."No.");
+ ExpenseReportApprovalMgt.Approve(ExpenseReportHeader, ApproverExpenseUser."No.");
+
+ // [THEN] The report has the expected ordered activity entries.
+ ExpenseActivityLogEntry.SetRange("Subject Table ID", Database::"Expense Report Header");
+ ExpenseActivityLogEntry.SetRange("Subject System ID", ExpenseReportHeader.SystemId);
+ ExpenseActivityLogEntry.SetCurrentKey("Entry No.");
+ ExpenseActivityLogEntry.FindSet();
+ Assert.AreEqual(Enum::"Expense Activity Event Type"::Created, ExpenseActivityLogEntry."Event Type", 'The first entry must record report creation.');
+ Assert.AreEqual(ExpenseReportHeader.SystemCreatedAt, ExpenseActivityLogEntry."Occurred At", 'The creation entry must use the report creation timestamp.');
+ ExpenseActivityLogEntry.Next();
+ Assert.AreEqual(Enum::"Expense Activity Event Type"::Submitted, ExpenseActivityLogEntry."Event Type", 'The second entry must record first submission.');
+ ExpenseActivityLogEntry.Next();
+ Assert.AreEqual(Enum::"Expense Activity Event Type"::Rejected, ExpenseActivityLogEntry."Event Type", 'The third entry must record rejection.');
+ Assert.AreEqual('Please explain the change.', ExpenseActivityLogEntry.Comment, 'The rejection entry must preserve the approver comment.');
+ ExpenseActivityLogEntry.Next();
+ Assert.AreEqual(Enum::"Expense Activity Event Type"::Resubmitted, ExpenseActivityLogEntry."Event Type", 'The fourth entry must record resubmission.');
+ ExpenseActivityLogEntry.Next();
+ Assert.AreEqual(Enum::"Expense Activity Event Type"::Approved, ExpenseActivityLogEntry."Event Type", 'The fifth entry must record approval.');
+ Assert.AreEqual(0, ExpenseActivityLogEntry.Next(), 'No additional approval lifecycle entries are expected.');
+ end;
+
+ [Test]
+ procedure ReopeningPendingApprovalLogsRecall()
+ var
+ SubmitterExpenseUser: Record "Expense User";
+ ApproverExpenseUser: Record "Expense User";
+ ExpenseReportHeader: Record "Expense Report Header";
+ ExpenseActivityLogEntry: Record "Expense Activity Log Entry";
+ ExpenseReportApprovalMgt: Codeunit "Expense Report Approval Mgmt";
+ EntryCountBeforeRejectedReopen: Integer;
+ begin
+ // [SCENARIO] Returning a pending report to Open is a recall, while reopening a rejected report is not logged.
+ // [GIVEN] A submitted expense report whose submitter is the current BC user.
+ Initialize();
+ CreateApprovalScenario(SubmitterExpenseUser, ApproverExpenseUser, ExpenseReportHeader);
+ ExpenseReportApprovalMgt.Submit(ExpenseReportHeader, SubmitterExpenseUser."No.");
+
+ // [WHEN] The pending report is reopened.
+ ExpenseReportApprovalMgt.ReopenSubmitted(ExpenseReportHeader);
+
+ // [THEN] A Recalled entry is appended.
+ ExpenseActivityLogEntry.SetRange("Subject System ID", ExpenseReportHeader.SystemId);
+ ExpenseActivityLogEntry.FindLast();
+ Assert.AreEqual(Enum::"Expense Activity Event Type"::Recalled, ExpenseActivityLogEntry."Event Type", 'Pending approval to Open must be recorded as recalled.');
+
+ // [WHEN] The same operation reopens a rejected report.
+ ExpenseReportHeader.Status := ExpenseReportHeader.Status::Rejected;
+ ExpenseReportHeader.Modify(true);
+ EntryCountBeforeRejectedReopen := ExpenseActivityLogEntry.Count();
+ ExpenseReportApprovalMgt.ReopenSubmitted(ExpenseReportHeader);
+
+ // [THEN] No additional activity entry is appended.
+ ExpenseActivityLogEntry.Reset();
+ ExpenseActivityLogEntry.SetRange("Subject System ID", ExpenseReportHeader.SystemId);
+ Assert.AreEqual(EntryCountBeforeRejectedReopen, ExpenseActivityLogEntry.Count(), 'Reopening a rejected report must not create an activity entry.');
+ end;
+
+ [Test]
+ procedure CategoriesSnapshotRemovesTrailingCategoryToFitEllipsis()
+ var
+ ExpenseUser: Record "Expense User";
+ ExpenseReportHeader: Record "Expense Report Header";
+ ExpenseReportLine: Record "Expense Report Line";
+ ExpenseActivityLogEntry: Record "Expense Activity Log Entry";
+ ExpenseActivityLogMgt: Codeunit "Expense Activity Log Mgt.";
+ Categories: JsonArray;
+ LastCategory: JsonToken;
+ EntryNo: BigInteger;
+ CategoryIndex: Integer;
+ begin
+ // [SCENARIO] An overflowing category snapshot remains valid and signals omitted categories.
+ // [GIVEN] A report with more maximum-length unique categories than the snapshot field can store.
+ Initialize();
+ LibraryExpense.CreateExpenseUser(ExpenseUser);
+ LibraryExpense.CreateExpenseReport(ExpenseReportHeader, ExpenseUser."No.", '', '');
+ for CategoryIndex := 1 to 90 do begin
+ Clear(ExpenseReportLine);
+ ExpenseReportLine."Document No." := ExpenseReportHeader."No.";
+ ExpenseReportLine."Line No." := CategoryIndex * 10000;
+ ExpenseReportLine."Expense Category" := CopyStr(PadStr(Format(CategoryIndex), 20, 'X'), 1, MaxStrLen(ExpenseReportLine."Expense Category"));
+ ExpenseReportLine."Receipt Attached" := CategoryIndex = 90;
+ ExpenseReportLine.Insert();
+ end;
+
+ // [WHEN] A Submitted entry captures the report contents.
+ EntryNo := ExpenseActivityLogMgt.LogExpenseReportEvent(
+ ExpenseReportHeader,
+ Enum::"Expense Activity Event Type"::Submitted,
+ Enum::"Expense Activity Initiator"::User,
+ Enum::"Expense Activity Actor Role"::Submitter,
+ ExpenseUser."No.",
+ '');
+
+ // [THEN] The category JSON fits, remains valid, and ends with an ellipsis.
+ ExpenseActivityLogEntry.Get(EntryNo);
+ Assert.IsTrue(StrLen(ExpenseActivityLogEntry.Categories) <= MaxStrLen(ExpenseActivityLogEntry.Categories), 'Categories must not exceed the field length.');
+ Assert.IsTrue(Categories.ReadFrom(ExpenseActivityLogEntry.Categories), 'Categories must remain valid JSON.');
+ Categories.Get(Categories.Count() - 1, LastCategory);
+ Assert.AreEqual('...', LastCategory.AsValue().AsText(), 'An overflowing category snapshot must end with an ellipsis.');
+ Assert.AreEqual(1, ExpenseActivityLogEntry."Attached Receipt Count", 'Attached receipt counting must continue after the category snapshot overflows.');
+ end;
+
+ [Test]
+ procedure ExistingReportStartsTimelineWhenResubmitted()
+ var
+ SubmitterExpenseUser: Record "Expense User";
+ ApproverExpenseUser: Record "Expense User";
+ ExpenseReportHeader: Record "Expense Report Header";
+ ExpenseActivityLogEntry: Record "Expense Activity Log Entry";
+ ExpenseReportApprovalMgt: Codeunit "Expense Report Approval Mgmt";
+ begin
+ // [SCENARIO] A report submitted before activity tracking starts gets a complete timeline when resubmitted.
+ // [GIVEN] A released report with an earlier submission timestamp but no activity entries.
+ Initialize();
+ CreateApprovalScenario(SubmitterExpenseUser, ApproverExpenseUser, ExpenseReportHeader);
+ ExpenseReportHeader."Submission DateTime" := CurrentDateTime() - 1000;
+ ExpenseReportHeader.Modify(true);
+
+ // [WHEN] The existing report is resubmitted.
+ ExpenseReportApprovalMgt.Submit(ExpenseReportHeader, SubmitterExpenseUser."No.");
+
+ // [THEN] The timeline starts with Created and records the action as Resubmitted.
+ ExpenseActivityLogEntry.SetRange("Subject System ID", ExpenseReportHeader.SystemId);
+ ExpenseActivityLogEntry.SetCurrentKey("Entry No.");
+ ExpenseActivityLogEntry.FindSet();
+ Assert.AreEqual(Enum::"Expense Activity Event Type"::Created, ExpenseActivityLogEntry."Event Type", 'The timeline must start with report creation.');
+ ExpenseActivityLogEntry.Next();
+ Assert.AreEqual(Enum::"Expense Activity Event Type"::Resubmitted, ExpenseActivityLogEntry."Event Type", 'An earlier submission timestamp must produce Resubmitted.');
+ Assert.AreEqual(0, ExpenseActivityLogEntry.Next(), 'Only creation and resubmission entries are expected.');
+ end;
+
+ [Test]
+ procedure ReopeningApprovedReportIsLogged()
+ var
+ SubmitterExpenseUser: Record "Expense User";
+ ApproverExpenseUser: Record "Expense User";
+ CurrentUserExpenseUser: Record "Expense User";
+ ExpenseReportHeader: Record "Expense Report Header";
+ ExpenseActivityLogEntry: Record "Expense Activity Log Entry";
+ ExpenseReportApprovalMgt: Codeunit "Expense Report Approval Mgmt";
+ begin
+ // [SCENARIO] Reopening an approved report records the approver lifecycle action.
+ // [GIVEN] An approved report whose approver is the only Expense User mapped to the current BC user.
+ Initialize();
+ CreateApprovalScenario(SubmitterExpenseUser, ApproverExpenseUser, ExpenseReportHeader);
+ CurrentUserExpenseUser.SetRange("User Id For Approvals", UserId());
+ CurrentUserExpenseUser.ModifyAll("User Id For Approvals", '');
+ ApproverExpenseUser."User Id For Approvals" :=
+ CopyStr(UserId(), 1, MaxStrLen(ApproverExpenseUser."User Id For Approvals"));
+ ApproverExpenseUser.Modify();
+ ExpenseReportApprovalMgt.Submit(ExpenseReportHeader, SubmitterExpenseUser."No.");
+ ExpenseReportApprovalMgt.Approve(ExpenseReportHeader, ApproverExpenseUser."No.");
+
+ // [WHEN] The approver reopens the approved report.
+ ExpenseReportApprovalMgt.ReopenApproved(ExpenseReportHeader);
+
+ // [THEN] ReopenedByApprover is the latest activity.
+ ExpenseActivityLogEntry.SetRange("Subject System ID", ExpenseReportHeader.SystemId);
+ ExpenseActivityLogEntry.FindLast();
+ Assert.AreEqual(Enum::"Expense Activity Event Type"::ReopenedByApprover, ExpenseActivityLogEntry."Event Type", 'Reopening an approved report must be logged.');
+ Assert.AreEqual(ApproverExpenseUser.SystemId, ExpenseActivityLogEntry."Actor Record System ID", 'The reopen entry must identify the approver.');
+ end;
+
+ [Test]
+ procedure ReassigningToPostedChangesOwnerButKeepsSubject()
+ var
+ ExpenseUser: Record "Expense User";
+ ExpenseReportHeader: Record "Expense Report Header";
+ PostedExpenseReportHeader: Record "Posted Expense Report Header";
+ ExpenseActivityLogEntry: Record "Expense Activity Log Entry";
+ ExpenseActivityLogMgt: Codeunit "Expense Activity Log Mgt.";
+ DecoyEntryNo: BigInteger;
+ OriginalEntryNo: BigInteger;
+ OriginalEntrySystemID: Guid;
+ OriginalSubjectSystemID: Guid;
+ begin
+ // [SCENARIO] Posting reassigns only the exact entries owned by the source report.
+ // [GIVEN] Two source-report entries and an unowned decoy sharing the posted header SystemId.
+ Initialize();
+ LibraryExpense.CreateExpenseUser(ExpenseUser);
+ LibraryExpense.CreateExpenseReport(ExpenseReportHeader, ExpenseUser."No.", '', '');
+ OriginalEntryNo := ExpenseActivityLogMgt.LogExpenseReportEvent(
+ ExpenseReportHeader,
+ Enum::"Expense Activity Event Type"::Created,
+ Enum::"Expense Activity Initiator"::User,
+ Enum::"Expense Activity Actor Role"::Submitter,
+ ExpenseUser."No.",
+ '');
+ ExpenseActivityLogMgt.LogExpenseReportEvent(
+ ExpenseReportHeader,
+ Enum::"Expense Activity Event Type"::Submitted,
+ Enum::"Expense Activity Initiator"::User,
+ Enum::"Expense Activity Actor Role"::Submitter,
+ ExpenseUser."No.",
+ '');
+ ExpenseActivityLogEntry.Get(OriginalEntryNo);
+ OriginalEntrySystemID := ExpenseActivityLogEntry.SystemId;
+ OriginalSubjectSystemID := ExpenseReportHeader.SystemId;
+
+ PostedExpenseReportHeader.Init();
+ PostedExpenseReportHeader."No." := CopyStr(LibraryUtility.GenerateGUID(), 1, MaxStrLen(PostedExpenseReportHeader."No."));
+ PostedExpenseReportHeader.Insert();
+
+ Clear(ExpenseActivityLogEntry);
+ ExpenseActivityLogEntry.Init();
+ ExpenseActivityLogEntry."Source Table ID" := Database::"Expense Report Header";
+ ExpenseActivityLogEntry."Source Record System ID" := PostedExpenseReportHeader.SystemId;
+ ExpenseActivityLogEntry."Subject Table ID" := Database::"Expense Report Header";
+ ExpenseActivityLogEntry."Subject System ID" := CreateGuid();
+ ExpenseActivityLogEntry."Event Type" := Enum::"Expense Activity Event Type"::Created;
+ ExpenseActivityLogEntry."Occurred At" := CurrentDateTime();
+ ExpenseActivityLogEntry.Insert();
+ DecoyEntryNo := ExpenseActivityLogEntry."Entry No.";
+
+ // [WHEN] The source report entries are reassigned to the posted report.
+ ExpenseActivityLogMgt.ReassignExpenseReportEntriesToPosted(ExpenseReportHeader, PostedExpenseReportHeader);
+
+ // [THEN] The source entries have the posted owner while their event and subject identities remain stable.
+ ExpenseActivityLogEntry.Reset();
+ ExpenseActivityLogEntry.SetRange("Source Table ID", Database::"Expense Report Header");
+ ExpenseActivityLogEntry.SetRange("Source Record System ID", ExpenseReportHeader.SystemId);
+ Assert.RecordIsEmpty(ExpenseActivityLogEntry);
+ ExpenseActivityLogEntry.Reset();
+ ExpenseActivityLogEntry.SetRange("Source Table ID", Database::"Posted Expense Report Header");
+ ExpenseActivityLogEntry.SetRange("Source Record System ID", PostedExpenseReportHeader.SystemId);
+ ExpenseActivityLogEntry.FindFirst();
+ Assert.RecordCount(ExpenseActivityLogEntry, 2);
+ Assert.AreEqual(Database::"Posted Expense Report Header", ExpenseActivityLogEntry."Source Table ID", 'Posted entries must be sourced from the posted report.');
+ Assert.AreEqual(OriginalSubjectSystemID, ExpenseActivityLogEntry."Subject System ID", 'Posting must not change the contract subject identity.');
+ ExpenseActivityLogEntry.SetRange(SystemId, OriginalEntrySystemID);
+ Assert.RecordIsNotEmpty(ExpenseActivityLogEntry);
+
+ // [THEN] The unmarked decoy remains owned by an active report.
+ ExpenseActivityLogEntry.Get(DecoyEntryNo);
+ Assert.AreEqual(Database::"Expense Report Header", ExpenseActivityLogEntry."Source Table ID", 'An unmarked active entry with the posted source GUID must not be reassigned.');
+ end;
+
+ [Test]
+ procedure SourceDeletionRemovesActivityEntries()
+ var
+ ExpenseUser: Record "Expense User";
+ ExpenseReportHeader: Record "Expense Report Header";
+ PostedExpenseReportHeader: Record "Posted Expense Report Header";
+ ExpenseActivityLogEntry: Record "Expense Activity Log Entry";
+ ExpenseActivityLogMgt: Codeunit "Expense Activity Log Mgt.";
+ begin
+ // [SCENARIO] Activity entries are deleted with the active or posted source document.
+ // [GIVEN] An activity entry reassigned from an active report to a posted report.
+ Initialize();
+ LibraryExpense.CreateExpenseUser(ExpenseUser);
+ LibraryExpense.CreateExpenseReport(ExpenseReportHeader, ExpenseUser."No.", '', '');
+ ExpenseActivityLogMgt.LogExpenseReportEvent(
+ ExpenseReportHeader,
+ Enum::"Expense Activity Event Type"::Submitted,
+ Enum::"Expense Activity Initiator"::User,
+ Enum::"Expense Activity Actor Role"::Submitter,
+ ExpenseUser."No.",
+ '');
+ PostedExpenseReportHeader.Init();
+ PostedExpenseReportHeader."No." := CopyStr(LibraryUtility.GenerateGUID(), 1, MaxStrLen(PostedExpenseReportHeader."No."));
+ PostedExpenseReportHeader.Insert();
+ ExpenseActivityLogMgt.ReassignExpenseReportEntriesToPosted(ExpenseReportHeader, PostedExpenseReportHeader);
+
+ // [WHEN] The active report is deleted after reassignment.
+ ExpenseReportHeader.Delete(true);
+
+ // [THEN] The entry remains with the posted source.
+ ExpenseActivityLogEntry.SetRange("Source Table ID", Database::"Posted Expense Report Header");
+ ExpenseActivityLogEntry.SetRange("Source Record System ID", PostedExpenseReportHeader.SystemId);
+ Assert.RecordIsNotEmpty(ExpenseActivityLogEntry);
+
+ // [WHEN] The posted source document is deleted.
+ PostedExpenseReportHeader.Delete(true);
+
+ // [THEN] Its activity entries are deleted.
+ Assert.RecordIsEmpty(ExpenseActivityLogEntry);
+ end;
+
+ local procedure CreateApprovalScenario(
+ var
+ SubmitterExpenseUser: Record "Expense User";
+ var
+ ApproverExpenseUser: Record "Expense User";
+ var
+ ExpenseReportHeader: Record "Expense Report Header"
+ )
+ var
+ ExpenseApprovalSetup: Record "Expense Approval Setup";
+ begin
+ LibraryExpense.CreateExpenseUser(SubmitterExpenseUser);
+ SubmitterExpenseUser."User Id For Approvals" := CopyStr(UserId(), 1, MaxStrLen(SubmitterExpenseUser."User Id For Approvals"));
+ SubmitterExpenseUser.Modify();
+
+ LibraryExpense.CreateExpenseUser(ApproverExpenseUser);
+ ApproverExpenseUser."Can Approve" := true;
+ ApproverExpenseUser."User Id For Approvals" := CopyStr(LibraryUtility.GenerateGUID(), 1, MaxStrLen(ApproverExpenseUser."User Id For Approvals"));
+ ApproverExpenseUser.Modify();
+ LibraryExpense.CreateExpenseApprovalSetup(ExpenseApprovalSetup, SubmitterExpenseUser."No.", ApproverExpenseUser."No.");
+
+ LibraryExpense.CreateExpenseReport(ExpenseReportHeader, SubmitterExpenseUser."No.", '', '');
+ ExpenseReportHeader.Status := ExpenseReportHeader.Status::Released;
+ ExpenseReportHeader.Modify(true);
+ end;
+
+ local procedure Initialize()
+ begin
+ LibraryTestInitialize.OnTestInitialize(Codeunit::"Expense Activity Log Test");
+ if IsInitialized then
+ exit;
+
+ LibraryExpense.SetupNumberSeriesInExpenseMgmt();
+ IsInitialized := true;
+ LibraryTestInitialize.OnAfterTestSuiteInitialize(Codeunit::"Expense Activity Log Test");
+ end;
+}
diff --git a/src/Apps/W1/ExpenseAgent/test/src/ExpenseEventSubsPermTest.Codeunit.al b/src/Apps/W1/ExpenseAgent/test/src/ExpenseEventSubsPermTest.Codeunit.al
index 3793db08e1f..4e6f30224b4 100644
--- a/src/Apps/W1/ExpenseAgent/test/src/ExpenseEventSubsPermTest.Codeunit.al
+++ b/src/Apps/W1/ExpenseAgent/test/src/ExpenseEventSubsPermTest.Codeunit.al
@@ -232,6 +232,7 @@ codeunit 148338 "Expense Event Subs. Perm. Test"
local procedure Initialize()
begin
LibraryTestInitialize.OnTestInitialize(Codeunit::"Expense Event Subs. Perm. Test");
+ RestoreFullPermissions();
LibraryExpense.CleanTransactionalData();
LibraryExpense.CleanUpBeforeTesting();
if IsInitialized then
diff --git a/src/Apps/W1/ExpenseAgent/test/src/ExpenseReportPostingTest.Codeunit.al b/src/Apps/W1/ExpenseAgent/test/src/ExpenseReportPostingTest.Codeunit.al
index 96c5c08243e..2b83eab1447 100644
--- a/src/Apps/W1/ExpenseAgent/test/src/ExpenseReportPostingTest.Codeunit.al
+++ b/src/Apps/W1/ExpenseAgent/test/src/ExpenseReportPostingTest.Codeunit.al
@@ -131,6 +131,70 @@ codeunit 148302 "Expense Report Posting Test"
Assert.RecordCount(PostedExpenseReportLine, 1);
end;
+ [Test]
+ [HandlerFunctions('ExpensesModalPageHandler,ConfirmHandler')]
+ procedure PostingExpenseReportLogsPostedActivity()
+ var
+ Expense: Record Expense;
+ Employee: Record Employee;
+ PostCode: Record "Post Code";
+ ExpensePaymentMethod: Record "Expense Payment Method";
+ ExpenseUser: Record "Expense User";
+ ExpenseReportHeader: Record "Expense Report Header";
+ PostedExpenseReportHeader: Record "Posted Expense Report Header";
+ ExpenseActivityLogEntry: Record "Expense Activity Log Entry";
+ ReleaseExpenseDocument: Codeunit "Release Expense Document";
+ CreateExpenseReport: Codeunit "Create Expense Report";
+ ExpenseReportPost: Codeunit "Expense Report-Post";
+ ExpenseActivityLogMgt: Codeunit "Expense Activity Log Mgt.";
+ OriginalSubjectSystemID: Guid;
+ Amount: Decimal;
+ begin
+ // [SCENARIO] Posting records a Posted activity entry and reassigns the report timeline to the posted source.
+ Initialize();
+
+ // [GIVEN] A released expense report with activity tracking already started.
+ LibraryERM.FindPostCode(PostCode);
+ Amount := LibraryRandom.RandInt(100);
+ CreateExpense(Expense, true, '', Amount);
+ LibraryExpense.FindExpensePaymentMethod(ExpensePaymentMethod, ExpensePaymentMethod."Reimbursement Type"::"Employee Paid");
+ Expense.Validate("Payment Method Code", ExpensePaymentMethod.Code);
+ Expense.Modify();
+ ExpenseUser.Get(Expense."Expense User No.");
+ Employee.Get(ExpenseUser."Employee No.");
+ LibraryExpense.UpdateExpenseAccountInEmployeePostingGroup(Employee."Employee Posting Group");
+ ReleaseExpenseDocument.PerformManualCheckAndRelease(Expense);
+ LibraryExpense.CreateExpenseReport(ExpenseReportHeader, ExpenseUser."No.", '', Expense."VAT Bus. Posting Group");
+ CreateExpenseReport.AddExpensesToReport(ExpenseReportHeader);
+ ExpenseReportHeader.PerformManualRelease();
+ OriginalSubjectSystemID := ExpenseReportHeader.SystemId;
+ ExpenseActivityLogMgt.LogExpenseReportEvent(
+ ExpenseReportHeader,
+ Enum::"Expense Activity Event Type"::Submitted,
+ Enum::"Expense Activity Initiator"::User,
+ Enum::"Expense Activity Actor Role"::Submitter,
+ ExpenseUser."No.",
+ '');
+
+ // [WHEN] The report is posted through Expense Report-Post.
+ ExpenseReportPost.PostExpenseReport(ExpenseReportHeader);
+
+ // [THEN] Submitted and Posted entries are sourced from the posted report and keep the original subject.
+ PostedExpenseReportHeader.SetRange("Expense User No.", ExpenseUser."No.");
+ PostedExpenseReportHeader.FindFirst();
+ ExpenseActivityLogEntry.SetRange("Source Table ID", Database::"Posted Expense Report Header");
+ ExpenseActivityLogEntry.SetRange("Source Record System ID", PostedExpenseReportHeader.SystemId);
+ Assert.RecordCount(ExpenseActivityLogEntry, 2);
+ ExpenseActivityLogEntry.SetRange("Subject Table ID", Database::"Expense Report Header");
+ ExpenseActivityLogEntry.SetRange("Subject System ID", OriginalSubjectSystemID);
+ ExpenseActivityLogEntry.SetCurrentKey("Subject Table ID", "Subject System ID", "Occurred At", "Entry No.");
+ ExpenseActivityLogEntry.FindLast();
+ Assert.AreEqual(Enum::"Expense Activity Event Type"::Posted, ExpenseActivityLogEntry."Event Type", 'The latest activity entry must be Posted.');
+ Assert.AreEqual(Database::User, ExpenseActivityLogEntry."Actor Table ID", 'Posting must identify the current BC User actor.');
+ Assert.IsFalse(IsNullGuid(ExpenseActivityLogEntry."Actor Record System ID"), 'Posting must persist the BC User SystemId.');
+ Assert.AreEqual(Amount, ExpenseActivityLogEntry."Amount (LCY)", 'Posted activity must preserve the report amount snapshot.');
+ end;
+
[Test]
[HandlerFunctions('ExpensesModalPageHandler,ConfirmHandler')]
procedure ExpenseReportMustBePostedForMultipleReleasedExpense()
diff --git a/src/Apps/W1/ExpenseAgent/test/src/LibraryExpense.Codeunit.al b/src/Apps/W1/ExpenseAgent/test/src/LibraryExpense.Codeunit.al
index 51be9afd94e..f686afbb501 100644
--- a/src/Apps/W1/ExpenseAgent/test/src/LibraryExpense.Codeunit.al
+++ b/src/Apps/W1/ExpenseAgent/test/src/LibraryExpense.Codeunit.al
@@ -614,6 +614,7 @@ codeunit 148300 "Library - Expense"
ExpenseReportLinePerDiem: Record "Expense Report Line Per Diem";
ExpenseRuleViolation: Record "Expense Rule Violation";
ExpenseReportRuleViolation: Record "Expense Report Rule Violation";
+ ExpenseActivityLogEntry: Record "Expense Activity Log Entry";
begin
// First, clear expense report references to avoid "cannot delete attachment" validation.
Expense.ModifyAll("Expense Report No.", '');
@@ -624,6 +625,7 @@ codeunit 148300 "Library - Expense"
ExpenseReportLineParticip.DeleteAll();
ExpenseReportLinePerDiem.DeleteAll();
ExpenseReportLine.DeleteAll();
+ ExpenseActivityLogEntry.DeleteAll();
ExpenseReportHeader.DeleteAll();
ExpenseReportRuleViolation.DeleteAll();
diff --git a/src/Apps/W1/ExpenseAgent/test/src/LibraryExpenseAgent.Codeunit.al b/src/Apps/W1/ExpenseAgent/test/src/LibraryExpenseAgent.Codeunit.al
new file mode 100644
index 00000000000..1b2cf7ee89c
--- /dev/null
+++ b/src/Apps/W1/ExpenseAgent/test/src/LibraryExpenseAgent.Codeunit.al
@@ -0,0 +1,55 @@
+// ------------------------------------------------------------------------------------------------
+// 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;
+
+codeunit 148341 "Library - Expense Agent"
+{
+ SingleInstance = true;
+
+ var
+ TempExpenseAgentSetup: Record "Expense Agent Setup" temporary;
+ ExpenseAgentSetupExisted: Boolean;
+ HasExpenseAgentSetupBackup: Boolean;
+
+ internal procedure BackupExpenseAgentSetup()
+ var
+ ExpenseAgentSetup: Record "Expense Agent Setup";
+ begin
+ if HasExpenseAgentSetupBackup then
+ RestoreExpenseAgentSetup();
+
+ TempExpenseAgentSetup.DeleteAll();
+ ExpenseAgentSetupExisted := ExpenseAgentSetup.Get();
+ if ExpenseAgentSetupExisted then begin
+ TempExpenseAgentSetup.Init();
+ TempExpenseAgentSetup.TransferFields(ExpenseAgentSetup, true);
+ TempExpenseAgentSetup.Insert();
+ end;
+ HasExpenseAgentSetupBackup := true;
+ end;
+
+ internal procedure RestoreExpenseAgentSetup()
+ var
+ ExpenseAgentSetup: Record "Expense Agent Setup";
+ begin
+ if not HasExpenseAgentSetupBackup then
+ exit;
+
+ if ExpenseAgentSetupExisted then begin
+ TempExpenseAgentSetup.Get();
+ ExpenseAgentSetup.Get();
+ ExpenseAgentSetup.TransferFields(TempExpenseAgentSetup, false);
+ ExpenseAgentSetup.Modify(false);
+ end else
+ if ExpenseAgentSetup.Get() then
+ ExpenseAgentSetup.Delete(false);
+
+ TempExpenseAgentSetup.DeleteAll();
+ Clear(ExpenseAgentSetupExisted);
+ Clear(HasExpenseAgentSetupBackup);
+ 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 380b2231c07..f2fbf699587 100644
--- a/src/DisabledTests/Expense_Agent_Tests/Expense_Agent_Tests.DisabledTest.json
+++ b/src/DisabledTests/Expense_Agent_Tests/Expense_Agent_Tests.DisabledTest.json
@@ -19,6 +19,46 @@
"codeunitName": "Expense Capabilities API Test",
"method": "CapabilitiesConsolidatedProjectsFollowsProjectFieldsViaAPI"
},
+ {
+ "codeunitId": 148318,
+ "codeunitName": "Expense Capabilities API Test",
+ "method": "ActivityLogCapabilityEnabledViaAPI"
+ },
+ {
+ "codeunitId": 148343,
+ "codeunitName": "Expense Activity Log API Test",
+ "method": "ActivityLogEntryIsExposedThroughReadOnlyAPI"
+ },
+ {
+ "codeunitId": 148343,
+ "codeunitName": "Expense Activity Log API Test",
+ "method": "ActivityLogAPIRejectsWrites"
+ },
+ {
+ "codeunitId": 148343,
+ "codeunitName": "Expense Activity Log API Test",
+ "method": "NonSnapshotActivityDoesNotExposeCurrencies"
+ },
+ {
+ "codeunitId": 148343,
+ "codeunitName": "Expense Activity Log API Test",
+ "method": "UnscopedActivityAPIHidesEntries"
+ },
+ {
+ "codeunitId": 148343,
+ "codeunitName": "Expense Activity Log API Test",
+ "method": "SubmitterHistoryReturnsCompleteTimelinesForSubmittedReports"
+ },
+ {
+ "codeunitId": 148343,
+ "codeunitName": "Expense Activity Log API Test",
+ "method": "ApproverHistoryReturnsCompleteTimelinesForActedOnReports"
+ },
+ {
+ "codeunitId": 148343,
+ "codeunitName": "Expense Activity Log API Test",
+ "method": "E2EActivityLogScenario"
+ },
{
"codeunitId": 148333,
"codeunitName": "Expense Agent Setup API Test",