From 55fca09555fd2623bac057076e23f2cd7c06c99c Mon Sep 17 00:00:00 2001 From: Alexander Yakunin Date: Mon, 17 Aug 2026 17:43:12 +0200 Subject: [PATCH 01/29] Update Expense VAT Specification Source --- .../app/src/APIs/ExpenseVATSpecAPI.Page.al | 1 + .../Pages/ExpenseVATSpecification.Page.al | 5 ++ .../app/src/Expense/Tables/Expense.Table.al | 8 ++ .../Tables/ExpenseVATSpecification.Table.al | 82 ++++++++++++------- .../Codeunits/ExpenseReportPost.Codeunit.al | 16 ++-- .../ExpenseReport/Pages/ExpenseReport.Page.al | 7 +- 6 files changed, 79 insertions(+), 40 deletions(-) diff --git a/src/Apps/W1/ExpenseAgent/app/src/APIs/ExpenseVATSpecAPI.Page.al b/src/Apps/W1/ExpenseAgent/app/src/APIs/ExpenseVATSpecAPI.Page.al index 9edfddd58aa..14afd9224ff 100644 --- a/src/Apps/W1/ExpenseAgent/app/src/APIs/ExpenseVATSpecAPI.Page.al +++ b/src/Apps/W1/ExpenseAgent/app/src/APIs/ExpenseVATSpecAPI.Page.al @@ -104,5 +104,6 @@ page 7085 "Expense VAT Spec. API" ExpenseAgentAPIValidation: Codeunit "Expense Agent API Validation"; begin ExpenseAgentAPIValidation.VerifyAgentAccess(); + Source := Source::Agent; end; } diff --git a/src/Apps/W1/ExpenseAgent/app/src/Expense/Pages/ExpenseVATSpecification.Page.al b/src/Apps/W1/ExpenseAgent/app/src/Expense/Pages/ExpenseVATSpecification.Page.al index c0eab5ebcb4..c48c7335d37 100644 --- a/src/Apps/W1/ExpenseAgent/app/src/Expense/Pages/ExpenseVATSpecification.Page.al +++ b/src/Apps/W1/ExpenseAgent/app/src/Expense/Pages/ExpenseVATSpecification.Page.al @@ -66,4 +66,9 @@ page 7084 "Expense VAT Specification" } } } + + trigger OnInit() + begin + Rec.Source := Rec.Source::"Manual"; + end; } diff --git a/src/Apps/W1/ExpenseAgent/app/src/Expense/Tables/Expense.Table.al b/src/Apps/W1/ExpenseAgent/app/src/Expense/Tables/Expense.Table.al index 0661c3eb9dd..c8e2f354df1 100644 --- a/src/Apps/W1/ExpenseAgent/app/src/Expense/Tables/Expense.Table.al +++ b/src/Apps/W1/ExpenseAgent/app/src/Expense/Tables/Expense.Table.al @@ -788,6 +788,7 @@ table 6900 Expense NonRefundableAmountCannotBeNegativeErr: Label '%1 cannot be in negative on Expense No. %2.', Comment = '%1 = Field Caption, %2 = Expense No.'; ExpenseUserMustBeLinkedToAnEmployeeErr: Label 'Expense User %1 must be linked to an Employee No.', Comment = '%1 = Expense User No.'; BillableCustomerAndProjectErr: Label 'You cannot use both %1 and %2 at the same time.', Comment = '%1 = Billable to Customer field caption, %2 = Project No. field caption'; + ModifyOrDeleteErr: Label 'Modifications and delete are not allowed for records created by the Expense Agent API.'; procedure AssistEdit() Result: Boolean begin @@ -1314,6 +1315,7 @@ table 6900 Expense TempExpenseVATSpec: Record "Expense VAT Specification" temporary; LineNo: Integer; begin + ; ExpenseAgentSetup.Get(); if ExpenseAgentSetup."Default VAT Bus. Posting Group" = '' then exit; @@ -1323,6 +1325,11 @@ table 6900 Expense exit; ExpenseVATSpec.SetRange("Expense No.", ExpenseNo); + ExpenseVATSpec.SetRange(Source, ExpenseVATSpec.Source::Agent); + if not ExpenseVATSpec.IsEmpty() then + error(ModifyOrDeleteErr); + + ExpenseVATSpec.SetRange(Source); ExpenseVATSpec.DeleteAll(); LineNo := 0; @@ -1335,6 +1342,7 @@ table 6900 Expense TempExpenseVATSpec.Modify(); end else begin TempExpenseVATSpec.Init(); + TempExpenseVATSpec.Source := TempExpenseVATSpec.Source::Manual; TempExpenseVATSpec."Expense No." := ExpenseNo; LineNo += 1; TempExpenseVATSpec."Line No." := LineNo; diff --git a/src/Apps/W1/ExpenseAgent/app/src/Expense/Tables/ExpenseVATSpecification.Table.al b/src/Apps/W1/ExpenseAgent/app/src/Expense/Tables/ExpenseVATSpecification.Table.al index 7f8c2aec658..792f937789c 100644 --- a/src/Apps/W1/ExpenseAgent/app/src/Expense/Tables/ExpenseVATSpecification.Table.al +++ b/src/Apps/W1/ExpenseAgent/app/src/Expense/Tables/ExpenseVATSpecification.Table.al @@ -34,10 +34,12 @@ table 6918 "Expense VAT Specification" trigger OnValidate() begin GetExpense(); - if Expense."Expense Category" <> '' then - Validate("Expense Category", "Expense Category"); - if Expense."Expense Subcategory" <> '' then - Validate("Expense Subcategory", "Expense Subcategory"); + if Source <> Source::Agent then begin + if Expense."Expense Category" <> '' then + Validate("Expense Category", "Expense Category"); + if Expense."Expense Subcategory" <> '' then + Validate("Expense Subcategory", "Expense Subcategory"); + end; end; } field(2; "Line No."; Integer) @@ -57,12 +59,14 @@ table 6918 "Expense VAT Specification" trigger OnValidate() begin - InitializeCurrency(); - "VAT Amount" := Round(Amount * "VAT %" / (100 + "VAT %"), Currency."Amount Rounding Precision", Currency.VATRoundingDirection()); - "VAT Base Amount" := Round(Amount - "VAT Amount", Currency."Amount Rounding Precision"); - "VAT Difference" := 0; - "VAT Amount (LCY)" := CalcVATAmountLCY(); - "VAT Base Amount (LCY)" := "Amount (LCY)" - "VAT Amount (LCY)"; + if Source <> Source::Agent then begin + InitializeCurrency(); + "VAT Amount" := Round(Amount * "VAT %" / (100 + "VAT %"), Currency."Amount Rounding Precision", Currency.VATRoundingDirection()); + "VAT Base Amount" := Round(Amount - "VAT Amount", Currency."Amount Rounding Precision"); + "VAT Difference" := 0; + "VAT Amount (LCY)" := CalcVATAmountLCY(); + "VAT Base Amount (LCY)" := "Amount (LCY)" - "VAT Amount (LCY)"; + end; end; } field(11; "VAT Base Amount"; Decimal) @@ -91,7 +95,8 @@ table 6918 "Expense VAT Specification" trigger OnValidate() begin - ValidateAmount(); + if Source <> Source::Agent then + ValidateAmount(); end; } /// @@ -151,17 +156,17 @@ table 6918 "Expense VAT Specification" trigger OnValidate() begin - GetExpense(); - if Expense."Currency Code" = '' then begin - Amount := "Amount (LCY)"; - Validate(Amount); - end else begin - TestField("Amount (LCY)"); - TestField(Amount); - Expense."Currency Factor" := Amount / "Amount (LCY)"; + if Source <> Source::Agent then begin + GetExpense(); + if Expense."Currency Code" = '' then begin + Amount := "Amount (LCY)"; + Validate(Amount); + end else begin + TestField("Amount (LCY)"); + TestField(Amount); + end; + Validate("VAT %"); end; - - Validate("VAT %"); end; } field(27; "Expense Category"; Code[20]) @@ -174,14 +179,16 @@ table 6918 "Expense VAT Specification" var ExpenseCategory: Record "Expense Category"; begin - if "Expense Category" = '' then - exit; - if "Expense Subcategory" <> '' then - exit; - if ExpenseCategory.Get("Expense Category") then begin - "VAT Prod. Posting Group" := ExpenseCategory."VAT Prod. Posting Group"; - "VAT %" := ExpenseCategory."Default VAT %"; - Validate("VAT %"); + if Source <> Source::Agent then begin + if "Expense Category" = '' then + exit; + if "Expense Subcategory" <> '' then + exit; + if ExpenseCategory.Get("Expense Category") then begin + "VAT Prod. Posting Group" := ExpenseCategory."VAT Prod. Posting Group"; + "VAT %" := ExpenseCategory."Default VAT %"; + Validate("VAT %"); + end; end; end; } @@ -196,6 +203,9 @@ table 6918 "Expense VAT Specification" ExpenseSubcategory: Record "Expense Subcategory"; ExpenseCategory: Record "Expense Category"; begin + if Source = Source::Agent then + exit; + if "Expense Subcategory" <> '' then begin if ExpenseSubcategory.Get("Expense Category", "Expense Subcategory") then begin "VAT Prod. Posting Group" := ExpenseSubcategory."VAT Prod. Posting Group"; @@ -214,6 +224,7 @@ table 6918 "Expense VAT Specification" field(40; Source; Enum "Expense VAT Spec Source") { Caption = 'Source'; + Editable = false; ToolTip = 'Specifies how this VAT specification line was created, for example whether it was entered manually or extracted automatically from a receipt.'; } field(41; Confidence; Decimal) @@ -249,11 +260,24 @@ table 6918 "Expense VAT Specification" end; end; + trigger OnModify() + begin + if Source = Source::Agent then + error(ModifyOrDeleteErr); + end; + + trigger OnDelete() + begin + if Source = Source::Agent then + error(ModifyOrDeleteErr); + end; + var Currency: Record Currency; CurrExchRate: Record "Currency Exchange Rate"; Expense: Record Expense; ExpenseAgentSetup: Record "Expense Agent Setup"; + ModifyOrDeleteErr: Label 'Modifications and delete are not allowed for records created by the Expense Agent API.'; local procedure CalcVATAmountLCY(): Decimal var 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..ee3c10a95a1 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 @@ -739,21 +739,18 @@ codeunit 6987 "Expense Report-Post" // Amounts come directly from the spec row — base (net) + VAT as captured on the receipt. GenJournalLine."Gen. Posting Type" := GenJournalLine."Gen. Posting Type"::Purchase; - GenJournalLine."Currency Code" := ExpenseReportLine."Expense Currency Code"; - GenJournalLine."Currency Factor" := ExpenseReportLine."Expense Currency Factor"; - GenJournalLine.Amount := ExpenseReportLineVATSpec."VAT Base Amount"; - GenJournalLine."Amount (LCY)" := ExpenseReportLineVATSpec."VAT Base Amount (LCY)"; + GenJournalLine.Validate("Amount", ExpenseReportLineVATSpec."VAT Base Amount (LCY)"); if ExpenseReportLineVATSpec."Reclaim Status" = ExpenseReportLineVATSpec."Reclaim Status"::"Approved" then begin GenJournalLine."VAT Bus. Posting Group" := ExpenseReportLineVATSpec."VAT Bus. Posting Group"; GenJournalLine."VAT Prod. Posting Group" := ExpenseReportLineVATSpec."VAT Prod. Posting Group"; GenJournalLine."VAT Posting" := GenJournalLine."VAT Posting"::"Manual VAT Entry"; GenJournalLine."VAT Calculation Type" := ExpenseReportLine."VAT Calculation Type"; - GenJournalLine."VAT Base Amount" := ExpenseReportLineVATSpec."VAT Base Amount"; + GenJournalLine."VAT %" := ExpenseReportLineVATSpec."VAT %"; + GenJournalLine."VAT Base Amount" := ExpenseReportLineVATSpec."VAT Base Amount (LCY)"; GenJournalLine."VAT Base Amount (LCY)" := ExpenseReportLineVATSpec."VAT Base Amount (LCY)"; - GenJournalLine."VAT Amount" := ExpenseReportLineVATSpec."VAT Amount"; + GenJournalLine."VAT Amount" := ExpenseReportLineVATSpec."VAT Amount (LCY)"; GenJournalLine."VAT Amount (LCY)" := ExpenseReportLineVATSpec."VAT Amount (LCY)"; - GenJournalLine."VAT %" := ExpenseReportLineVATSpec."VAT %"; // VAT Reclaim % is used to route the non-deductible portion of VAT to the correct accounts via VAT Posting Setup. if (ExpenseReportLineVATSpec."Reclaim %" <> 100) and (ExpenseReportLineVATSpec."VAT Amount" <> 0) then begin @@ -763,9 +760,8 @@ codeunit 6987 "Expense Report-Post" end; end else begin // VAT is not reclaimable: include VAT in the expense amount (gross) and do not create a VAT entry. - GenJournalLine.Amount := ExpenseReportLineVATSpec."VAT Base Amount" + ExpenseReportLineVATSpec."VAT Amount"; - GenJournalLine."Amount (LCY)" := ExpenseReportLineVATSpec."VAT Base Amount (LCY)" + ExpenseReportLineVATSpec."VAT Amount (LCY)"; - + GenJournalLine.Amount := ExpenseReportLineVATSpec."VAT Base Amount (LCY)" + ExpenseReportLineVATSpec."VAT Amount (LCY)"; + GenJournalLine."Amount (LCY)" := GenJournalLine.Amount; GenJournalLine."VAT Posting" := GenJournalLine."VAT Posting"::"Automatic VAT Entry"; GenJournalLine."VAT %" := 0; GenJournalLine."VAT Amount" := 0; 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..7213cc0571e 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 @@ -460,6 +460,7 @@ page 6910 "Expense Report" CurrPage.SaveRecord(); end; } +#if not CLEAN29 action(VATSpecification) { ApplicationArea = Basic, Suite; @@ -468,8 +469,12 @@ page 6910 "Expense Report" RunObject = Page "Expense Report Line VAT Spec."; RunPageLink = "Document No." = field("No."), "Document Line No." = const(0); ToolTip = 'View the VAT details for the record.'; - Visible = (Rec."No." <> '') and AllowVATReclaim; + Visible = false; + ObsoleteReason = 'Replaced by Expense Report Statistics'; + ObsoleteState = Pending; + ObsoleteTag = '29.0'; } +#endif action("Spend Request") { ApplicationArea = Basic, Suite; From f69c885394ab2db6dc00bbe6909a19e711421646 Mon Sep 17 00:00:00 2001 From: Alexander Yakunin Date: Mon, 17 Aug 2026 18:12:50 +0200 Subject: [PATCH 02/29] Update test --- .../W1/ExpenseAgent/test/src/ExpensePostingVATTest.Codeunit.al | 1 + 1 file changed, 1 insertion(+) diff --git a/src/Apps/W1/ExpenseAgent/test/src/ExpensePostingVATTest.Codeunit.al b/src/Apps/W1/ExpenseAgent/test/src/ExpensePostingVATTest.Codeunit.al index 4f28fd7ca42..c1c2f3bf16e 100644 --- a/src/Apps/W1/ExpenseAgent/test/src/ExpensePostingVATTest.Codeunit.al +++ b/src/Apps/W1/ExpenseAgent/test/src/ExpensePostingVATTest.Codeunit.al @@ -650,6 +650,7 @@ codeunit 148330 "Expense Posting VAT Test" RecordRef: RecordRef; begin ExpenseVATSpecification.Init(); + ExpenseVATSpecification.Source := ExpenseVATSpecification.Source::Manual; ExpenseVATSpecification.Validate("Expense No.", Expense."No."); RecordRef.GetTable(ExpenseVATSpecification); ExpenseVATSpecification.Validate("Line No.", LibraryUtility.GetNewLineNo(RecordRef, ExpenseVATSpecification.FieldNo("Line No."))); From c941d8596ac171b035d0732e1a19c38557aa5fcb Mon Sep 17 00:00:00 2001 From: Alexander Yakunin Date: Mon, 17 Aug 2026 19:03:11 +0200 Subject: [PATCH 03/29] Add Approve All action --- .../Pages/ExpenseReportLineVATSpec.Page.al | 26 +++++++++++++++++++ 1 file changed, 26 insertions(+) diff --git a/src/Apps/W1/ExpenseAgent/app/src/ExpenseReport/Pages/ExpenseReportLineVATSpec.Page.al b/src/Apps/W1/ExpenseAgent/app/src/ExpenseReport/Pages/ExpenseReportLineVATSpec.Page.al index 5a2b365e1bf..4b826db7282 100644 --- a/src/Apps/W1/ExpenseAgent/app/src/ExpenseReport/Pages/ExpenseReportLineVATSpec.Page.al +++ b/src/Apps/W1/ExpenseAgent/app/src/ExpenseReport/Pages/ExpenseReportLineVATSpec.Page.al @@ -154,6 +154,29 @@ page 7081 "Expense Report Line VAT Spec." CurrPage.Update(false); end; } + action(ApproveAllReclaims) + { + ApplicationArea = Basic, Suite; + Caption = 'Approve All Reclaims'; + Image = Approve; + ToolTip = 'Approve all VAT reclaims for the selected expense report line.'; + + trigger OnAction() + var + ExpenseReportLineVATSpec: Record "Expense Report Line VAT Spec."; + begin + ExpenseReportLineVATSpec.SetRange("Document No.", Rec."Document No."); + ExpenseReportLineVATSpec.SetRange("Document Line No.", Rec."Document Line No."); + if ExpenseReportLineVATSpec.FindSet() then + repeat + if ExpenseReportLineVATSpec."Reclaim Status" = ExpenseReportLineVATSpec."Reclaim Status"::Pending then begin + ExpenseReportLineVATSpec.Validate("Reclaim Status", Rec."Reclaim Status"::Approved); + ExpenseReportLineVATSpec.Modify(true); + end; + until ExpenseReportLineVATSpec.Next() = 0; + CurrPage.Update(false); + end; + } action(RejectReclaim) { ApplicationArea = Basic, Suite; @@ -178,6 +201,9 @@ page 7081 "Expense Report Line VAT Spec." actionref(ApproveReclaim_Promoted; ApproveReclaim) { } + actionref(ApproveAllReclaims_Promoted; ApproveAllReclaims) + { + } actionref(RejectReclaim_Promoted; RejectReclaim) { } From f4daed4bff845d81498bcadfac45f0ed87030f0d Mon Sep 17 00:00:00 2001 From: Alexander Yakunin Date: Tue, 18 Aug 2026 09:45:05 +0200 Subject: [PATCH 04/29] Fix warnings --- .../app/src/ExpenseReport/Pages/ExpenseReport.Page.al | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) 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 7213cc0571e..1354e021cb3 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 @@ -624,9 +624,11 @@ page 6910 "Expense Report" actionref(dimension_Promoted; Dimensions) { } +#if not CLEAN29 actionref(VATSpecification_Promoted; VATSpecification) { } +#endif actionref("Spend Request_Promoted"; "Spend Request") { } @@ -688,7 +690,6 @@ page 6910 "Expense Report" DocNoVisible: Boolean; ExpenseUserNo: Code[20]; ApproverComment: Text; - AllowVATReclaim: Boolean; ApprovalActionsEnabled: Boolean; protected var @@ -705,9 +706,6 @@ page 6910 "Expense Report" ReopenSubmittedEnabled := ExpenseReportApprovalMgt.CanPerformApprovalAction(Rec, RefActionType::"Reopen Submitted"); ApproveEnabled := ExpenseReportApprovalMgt.CanPerformApprovalAction(Rec, RefActionType::Approve); ReopenApprovedEnabled := ExpenseReportApprovalMgt.CanPerformApprovalAction(Rec, RefActionType::"Reopen Approved"); - - ExpenseAgentSetup.GetRecordOnce(); - AllowVATReclaim := ExpenseAgentSetup."Allow VAT Reclaim"; ApprovalActionsEnabled := ExpenseAgentSetup."Enable Agent" and ApproveEnabled and (Rec."Approver Expense User ID" = UserId()); end; From 68792b70df90909b66fd8b77dbfca024996932ce Mon Sep 17 00:00:00 2001 From: Alexander Yakunin Date: Tue, 18 Aug 2026 15:32:46 +0200 Subject: [PATCH 05/29] Update VAT posting and rounding in different scenarios --- .../Pages/ExpenseVATSpecification.Page.al | 3 +- .../app/src/Expense/Tables/Expense.Table.al | 80 ++++-- .../Codeunits/CreateExpenseReport.Codeunit.al | 3 + .../Codeunits/ExpenseReportPost.Codeunit.al | 130 +++++---- .../Pages/ExpenseReportLineVATSpec.Page.al | 31 ++- .../Pages/ExpenseReportVATSpec.Page.al | 38 +++ .../Pages/PostedExpenseReportVATSpec.Page.al | 38 +++ .../Tables/ExpenseReportHeader.Table.al | 15 ++ .../Tables/ExpenseReportLine.Table.al | 29 +- .../Tables/ExpenseReportLineVATSpec.Table.al | 241 ++++++++++++++++- .../Tables/PostedExpRepLineVATSpec.Table.al | 40 +++ .../src/ExpensePostingVATTest.Codeunit.al | 251 ++++++++++++++++++ .../test/src/ExpenseReportTest.Codeunit.al | 40 ++- 13 files changed, 839 insertions(+), 100 deletions(-) diff --git a/src/Apps/W1/ExpenseAgent/app/src/Expense/Pages/ExpenseVATSpecification.Page.al b/src/Apps/W1/ExpenseAgent/app/src/Expense/Pages/ExpenseVATSpecification.Page.al index c48c7335d37..43a54638637 100644 --- a/src/Apps/W1/ExpenseAgent/app/src/Expense/Pages/ExpenseVATSpecification.Page.al +++ b/src/Apps/W1/ExpenseAgent/app/src/Expense/Pages/ExpenseVATSpecification.Page.al @@ -67,8 +67,9 @@ page 7084 "Expense VAT Specification" } } - trigger OnInit() + trigger OnInsertRecord(BelowxRec: Boolean): Boolean begin Rec.Source := Rec.Source::"Manual"; + exit(true); end; } diff --git a/src/Apps/W1/ExpenseAgent/app/src/Expense/Tables/Expense.Table.al b/src/Apps/W1/ExpenseAgent/app/src/Expense/Tables/Expense.Table.al index c8e2f354df1..f42e85a3dee 100644 --- a/src/Apps/W1/ExpenseAgent/app/src/Expense/Tables/Expense.Table.al +++ b/src/Apps/W1/ExpenseAgent/app/src/Expense/Tables/Expense.Table.al @@ -1310,49 +1310,71 @@ table 6900 Expense internal procedure UpdateVATSpecification(ExpenseNo: Code[20]) var + ExpenseCategory: Record "Expense Category"; ExpenseItemization: Record "Expense Itemization"; ExpenseVATSpec: Record "Expense VAT Specification"; TempExpenseVATSpec: Record "Expense VAT Specification" temporary; LineNo: Integer; begin - ; ExpenseAgentSetup.Get(); if ExpenseAgentSetup."Default VAT Bus. Posting Group" = '' then exit; - ExpenseItemization.SetRange("Expense No.", ExpenseNo); - if ExpenseItemization.IsEmpty() then - exit; - ExpenseVATSpec.SetRange("Expense No.", ExpenseNo); ExpenseVATSpec.SetRange(Source, ExpenseVATSpec.Source::Agent); if not ExpenseVATSpec.IsEmpty() then error(ModifyOrDeleteErr); - ExpenseVATSpec.SetRange(Source); - ExpenseVATSpec.DeleteAll(); - - LineNo := 0; - ExpenseItemization.FindSet(); - repeat - TempExpenseVATSpec.SetRange("Expense Category", ExpenseItemization."Expense Category Code"); - TempExpenseVATSpec.SetRange("Expense Subcategory", ExpenseItemization."Expense Subcategory Code"); - if TempExpenseVATSpec.FindFirst() then begin - TempExpenseVATSpec."Amount" += ExpenseItemization."Amount"; - TempExpenseVATSpec.Modify(); - end else begin - TempExpenseVATSpec.Init(); - TempExpenseVATSpec.Source := TempExpenseVATSpec.Source::Manual; - TempExpenseVATSpec."Expense No." := ExpenseNo; - LineNo += 1; - TempExpenseVATSpec."Line No." := LineNo; - TempExpenseVATSpec.Validate("Expense Category", ExpenseItemization."Expense Category Code"); - TempExpenseVATSpec.Validate("Expense Subcategory", ExpenseItemization."Expense Subcategory Code"); - TempExpenseVATSpec.Validate("VAT Bus. Posting Group", ExpenseAgentSetup."Default VAT Bus. Posting Group"); - TempExpenseVATSpec.Validate("Amount", ExpenseItemization.Amount); - TempExpenseVATSpec.Insert(); - end; - until ExpenseItemization.Next() = 0; + ExpenseCategory.Get("Expense Category"); + case ExpenseCategory."Expense Detail Required" of + ExpenseCategory."Expense Detail Required"::Itemize: + begin + ExpenseItemization.SetRange("Expense No.", ExpenseNo); + if not ExpenseItemization.IsEmpty() then begin + ExpenseVATSpec.SetRange(Source); + ExpenseVATSpec.DeleteAll(); + + LineNo := 0; + ExpenseItemization.FindSet(); + repeat + TempExpenseVATSpec.SetRange("Expense Category", ExpenseItemization."Expense Category Code"); + TempExpenseVATSpec.SetRange("Expense Subcategory", ExpenseItemization."Expense Subcategory Code"); + if TempExpenseVATSpec.FindFirst() then begin + TempExpenseVATSpec.Validate(Amount, TempExpenseVATSpec.Amount + ExpenseItemization.Amount); + TempExpenseVATSpec.Modify(); + end else begin + TempExpenseVATSpec.Init(); + TempExpenseVATSpec.Source := TempExpenseVATSpec.Source::Manual; + TempExpenseVATSpec."Expense No." := ExpenseNo; + LineNo += 1; + TempExpenseVATSpec."Line No." := LineNo; + TempExpenseVATSpec.Validate("Expense Category", ExpenseItemization."Expense Category Code"); + TempExpenseVATSpec.Validate("Expense Subcategory", ExpenseItemization."Expense Subcategory Code"); + TempExpenseVATSpec.Validate("VAT Bus. Posting Group", ExpenseAgentSetup."Default VAT Bus. Posting Group"); + TempExpenseVATSpec.Validate("Amount", ExpenseItemization.Amount); + TempExpenseVATSpec.Insert(); + end; + until ExpenseItemization.Next() = 0; + end; + end; + else + if (ExpenseCategory."VAT Prod. Posting Group" <> '') and + (ExpenseCategory."Default VAT %" <> 0) + then begin + ExpenseVATSpec.SetRange(Source); + ExpenseVATSpec.DeleteAll(); + + TempExpenseVATSpec.Init(); + TempExpenseVATSpec.Source := TempExpenseVATSpec.Source::Manual; + TempExpenseVATSpec."Expense No." := ExpenseNo; + LineNo += 1; + TempExpenseVATSpec."Line No." := LineNo; + TempExpenseVATSpec.Validate("Expense Category", "Expense Category"); + TempExpenseVATSpec.Validate("VAT Bus. Posting Group", ExpenseAgentSetup."Default VAT Bus. Posting Group"); + TempExpenseVATSpec.Validate("Amount", Amount); + TempExpenseVATSpec.Insert(); + end; + end; TempExpenseVATSpec.Reset(); if TempExpenseVATSpec.FindSet() then diff --git a/src/Apps/W1/ExpenseAgent/app/src/ExpenseReport/Codeunits/CreateExpenseReport.Codeunit.al b/src/Apps/W1/ExpenseAgent/app/src/ExpenseReport/Codeunits/CreateExpenseReport.Codeunit.al index 32f7a184923..0f45b7c4b46 100644 --- a/src/Apps/W1/ExpenseAgent/app/src/ExpenseReport/Codeunits/CreateExpenseReport.Codeunit.al +++ b/src/Apps/W1/ExpenseAgent/app/src/ExpenseReport/Codeunits/CreateExpenseReport.Codeunit.al @@ -327,6 +327,8 @@ codeunit 6983 "Create Expense Report" ExpenseReportLineVATSpec."VAT Amount" := ExpenseVATSpec."VAT Amount"; ExpenseReportLineVATSpec.Amount := ExpenseVATSpec.Amount; ExpenseReportLineVATSpec."VAT Difference" := ExpenseVATSpec."VAT Difference"; + ExpenseReportLineVATSpec."Currency Code" := Expense."Currency Code"; + ExpenseReportLineVATSpec."Currency Factor" := Expense."Currency Factor"; ExpenseReportLineVATSpec."VAT Bus. Posting Group" := ExpenseVATSpec."VAT Bus. Posting Group"; ExpenseReportLineVATSpec."VAT Prod. Posting Group" := ExpenseVATSpec."VAT Prod. Posting Group"; ExpenseReportLineVATSpec."VAT Amount (LCY)" := ExpenseVATSpec."VAT Amount (LCY)"; @@ -335,6 +337,7 @@ codeunit 6983 "Create Expense Report" ExpenseReportLineVATSpec.Source := ExpenseVATSpec.Source; ExpenseReportLineVATSpec.Confidence := ExpenseVATSpec.Confidence; ExpenseReportLineVATSpec."Source Spec Line No." := ExpenseVATSpec."Line No."; + ExpenseReportLineVATSpec.UpdateReimbursementAmounts(); if ExpenseSubcategory.Get(ExpenseVATSpec."Expense Category", ExpenseVATSpec."Expense Subcategory") then ExpenseReportLineVATSpec.Validate("Reclaim %", ExpenseSubcategory."Default VAT Reclaim %") else 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 ee3c10a95a1..7210399660e 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 @@ -71,6 +71,7 @@ codeunit 6987 "Expense Report-Post" AgentNotEnabledErr: Label 'Please make sure the Expense Agent is active.'; CommunicationDisabledErr: Label 'Sending emails to users is turned off. Turn on Communication for the Expense Agent before sending reimbursement notifications.'; NoNoreplyAccountErr: Label 'No account is set for sending emails. Set the send mail account for the Expense Agent before sending reimbursement notifications.'; + NotApprovedForVATReclaimCategoryErr: Label 'VAT Reclaim Status is not set for Line with Expense Category %1.', Comment = '%1 = Expense Category'; NotApprovedForVATReclaimErr: Label 'VAT Reclaim Status is not set for Line with Expense Category %1 and Expense Subcategory %2.', Comment = '%1 = Expense Category, %2 = Expense Subcategory'; internal procedure RunWithCheck(var ExpenseReportHeader: Record "Expense Report Header") @@ -231,8 +232,12 @@ codeunit 6987 "Expense Report-Post" ExpenseReportLineVATSpec.SetRange("Document Line No.", ExpenseReportLine."Line No."); if ExpenseReportLineVATSpec.FindSet() then repeat - if ExpenseReportLineVATSpec."Reclaim Status" = ExpenseReportLineVATSpec."Reclaim Status"::"Pending" then + if ExpenseReportLineVATSpec."Reclaim Status" = ExpenseReportLineVATSpec."Reclaim Status"::"Pending" then begin + if ExpenseReportLineVATSpec."Expense Subcategory" = '' then + Error(NotApprovedForVATReclaimCategoryErr, ExpenseReportLineVATSpec."Expense Category"); + Error(NotApprovedForVATReclaimErr, ExpenseReportLineVATSpec."Expense Category", ExpenseReportLineVATSpec."Expense Subcategory"); + end; until ExpenseReportLineVATSpec.Next() = 0; end; @@ -416,7 +421,7 @@ codeunit 6987 "Expense Report-Post" exit; if ExpenseReportLine.Refundable then - PostCompanyPaidExpenseJournal(ExpenseReportHeader, ExpenseReportLine, PostedExpReportLine, RefundableAmountLCY); + PostCompanyPaidExpenseJournal(ExpenseReportHeader, ExpenseReportLine, PostedExpReportLine, RefundableAmount, RefundableAmountLCY); if ExpenseReportLine."Reimbursable Amount (LCY)" < 0 then PostNonRefundableJnlLine(ExpenseReportHeader, ExpenseReportLine, PostedExpReportLine); @@ -459,10 +464,11 @@ codeunit 6987 "Expense Report-Post" RefundableAmount := GenJournalLine.Amount + GenJournalLine."VAT Amount"; RefundableAmountLCY := GenJournalLine."Amount (LCY)" + GenJournalLine."VAT Amount (LCY)"; - if ShouldConsiderPostingRoundingDifference(PostedExpenseReportHeader."Reimbursement Currency Code", ExpenseReportLine."Expense Currency Code") then - if ExpenseReportLine."Reimbursement Type" = ExpenseReportLine."Reimbursement Type"::"Employee Paid" then - if Abs(RefundableAmountLCY) <> Abs(ExpenseReportLine."Reimbursable Amount (LCY)") then - PostRoundingDifferenceOnCurrency(GenJournalLine, ExpenseReportLine, ExpenseReportLine."Reimbursable Amount (LCY)" - RefundableAmountLCY); + if ExpenseReportLine."Reimbursement Type" = ExpenseReportLine."Reimbursement Type"::"Employee Paid" then + PostRoundingDifferenceOnCurrency( + ExpenseReportHeader, ExpenseReportLine, + ExpenseReportLine."Reimbursable Amount" - RefundableAmount, + ExpenseReportLine."Reimbursable Amount (LCY)" - RefundableAmountLCY); end; local procedure PostRefundableJnlLineFromSpecs(ExpenseReportHeader: Record "Expense Report Header"; ExpenseReportLine: Record "Expense Report Line"; PostedExpReportLine: Record "Posted Expense Report Line"; var ExpenseReportLineVATSpec: Record "Expense Report Line VAT Spec."; var RefundableAmount: Decimal; var RefundableAmountLCY: Decimal) @@ -495,7 +501,7 @@ codeunit 6987 "Expense Report-Post" GenJnlPostLine.RunWithCheck(GenJournalLine); // Accumulate gross (base + VAT) from the spec row for rounding/balance checks. - RefundableAmount += ExpenseReportLineVATSpec."VAT Base Amount" + ExpenseReportLineVATSpec."VAT Amount"; + RefundableAmount += ExpenseReportLineVATSpec."VAT Base Amount (RCY)" + ExpenseReportLineVATSpec."VAT Amount (RCY)"; RefundableAmountLCY += ExpenseReportLineVATSpec."VAT Base Amount (LCY)" + ExpenseReportLineVATSpec."VAT Amount (LCY)"; until ExpenseReportLineVATSpec.Next() = 0; @@ -505,13 +511,14 @@ codeunit 6987 "Expense Report-Post" AmountToEmployeeLCY += ExpenseReportLine."Reimbursable Amount (LCY)"; end; - if ShouldConsiderPostingRoundingDifference(PostedExpenseReportHeader."Reimbursement Currency Code", ExpenseReportLine."Expense Currency Code") then - if ExpenseReportLine."Reimbursement Type" = ExpenseReportLine."Reimbursement Type"::"Employee Paid" then - if Abs(RefundableAmountLCY) <> Abs(ExpenseReportLine."Reimbursable Amount (LCY)") then - PostRoundingDifferenceOnCurrency(GenJournalLine, ExpenseReportLine, ExpenseReportLine."Reimbursable Amount (LCY)" - RefundableAmountLCY); + if ExpenseReportLine."Reimbursement Type" = ExpenseReportLine."Reimbursement Type"::"Employee Paid" then + PostRoundingDifferenceOnCurrency( + ExpenseReportHeader, ExpenseReportLine, + ExpenseReportLine."Reimbursable Amount" - RefundableAmount, + ExpenseReportLine."Reimbursable Amount (LCY)" - RefundableAmountLCY); end; - local procedure PostCompanyPaidExpenseJournal(ExpenseReportHeader: Record "Expense Report Header"; ExpenseReportLine: Record "Expense Report Line"; PostedExpReportLine: Record "Posted Expense Report Line"; RefundableAmountLCY: Decimal) + local procedure PostCompanyPaidExpenseJournal(ExpenseReportHeader: Record "Expense Report Header"; ExpenseReportLine: Record "Expense Report Line"; PostedExpReportLine: Record "Posted Expense Report Line"; RefundableAmount: Decimal; RefundableAmountLCY: Decimal) var GenJournalLine: Record "Gen. Journal Line"; begin @@ -521,15 +528,17 @@ codeunit 6987 "Expense Report-Post" GenJnlPostLine.RunWithCheck(GenJournalLine); - if ShouldConsiderPostingRoundingDifference(PostedExpenseReportHeader."Reimbursement Currency Code", ExpenseReportLine."Expense Currency Code") then - if Abs(RefundableAmountLCY) <> Abs(GenJournalLine."Amount (LCY)") then - PostRoundingDifferenceOnCurrency(GenJournalLine, ExpenseReportLine, Abs(GenJournalLine."Amount (LCY)") - RefundableAmountLCY); + PostRoundingDifferenceOnCurrency( + ExpenseReportHeader, ExpenseReportLine, + Abs(GenJournalLine.Amount) - RefundableAmount, + Abs(GenJournalLine."Amount (LCY)") - RefundableAmountLCY); end; local procedure PostNonRefundableJnlLine(ExpenseReportHeader: Record "Expense Report Header"; ExpenseReportLine: Record "Expense Report Line"; PostedExpReportLine: Record "Posted Expense Report Line") var GenJournalLine: Record "Gen. Journal Line"; ExpenseReportLineVATSpec: Record "Expense Report Line VAT Spec."; + RefundableAmount: Decimal; RefundableLCY: Decimal; begin ExpenseReportLineVATSpec.SetRange("Document No.", ExpenseReportLine."Document No."); @@ -548,16 +557,20 @@ codeunit 6987 "Expense Report-Post" GenJnlPostLine.RunWithCheck(GenJournalLine); + RefundableAmount := GenJournalLine.Amount + GenJournalLine."VAT Amount"; RefundableLCY := GenJournalLine."Amount (LCY)" + GenJournalLine."VAT Amount (LCY)"; - if ShouldConsiderPostingRoundingDifference(PostedExpenseReportHeader."Reimbursement Currency Code", ExpenseReportLine."Expense Currency Code") then - if Abs(ExpenseReportLine."Reimbursable Amount (LCY)") <> Abs(RefundableLCY) then - PostRoundingDifferenceOnCurrency(GenJournalLine, ExpenseReportLine, ExpenseReportLine."Reimbursable Amount (LCY)" - RefundableLCY); + PostRoundingDifferenceOnCurrency( + ExpenseReportHeader, ExpenseReportLine, + ExpenseReportLine."Reimbursable Amount" - RefundableAmount, + ExpenseReportLine."Reimbursable Amount (LCY)" - RefundableLCY); end; local procedure PostNonRefundableJnlLineFromSpecs(ExpenseReportHeader: Record "Expense Report Header"; ExpenseReportLine: Record "Expense Report Line"; var ExpenseReportLineVATSpec: Record "Expense Report Line VAT Spec.") var GenJournalLine: Record "Gen. Journal Line"; GlobalEntryCopied: Boolean; + PostedAmount: Decimal; + PostedAmountLCY: Decimal; begin // Each spec row produces its own Gen. Journal Line and VAT Entry. // Amounts are taken directly from the spec — no proportional splitting. @@ -574,11 +587,17 @@ codeunit 6987 "Expense Report-Post" end; GenJnlPostLine.RunWithCheck(GenJournalLine); + PostedAmount += ExpenseReportLineVATSpec."VAT Base Amount (RCY)" + ExpenseReportLineVATSpec."VAT Amount (RCY)"; + PostedAmountLCY += ExpenseReportLineVATSpec."VAT Base Amount (LCY)" + ExpenseReportLineVATSpec."VAT Amount (LCY)"; until ExpenseReportLineVATSpec.Next() = 0; // Accumulate employee reimbursement amount once per line. AmountToEmployee += ExpenseReportLine."Reimbursable Amount"; AmountToEmployeeLCY += ExpenseReportLine."Reimbursable Amount (LCY)"; + PostRoundingDifferenceOnCurrency( + ExpenseReportHeader, ExpenseReportLine, + ExpenseReportLine."Reimbursable Amount" - PostedAmount, + ExpenseReportLine."Reimbursable Amount (LCY)" - PostedAmountLCY); end; local procedure SetupNonRefundableAccountForSpec(var GenJournalLine: Record "Gen. Journal Line"; ExpenseReportLine: Record "Expense Report Line"; ExpenseReportLineVATSpec: Record "Expense Report Line VAT Spec.") @@ -597,11 +616,6 @@ codeunit 6987 "Expense Report-Post" GenJournalLine."Account No." := ExpensePostingGroup."Non-Refundable Debit Account"; end; - local procedure ShouldConsiderPostingRoundingDifference(ReimbursementCurrencyCode: Code[10]; ExpenseCurrencyCode: Code[10]): Boolean - begin - exit((ReimbursementCurrencyCode = '') and (ExpenseCurrencyCode <> '')); - end; - local procedure PostProjectJnlLine(PostedExpReportLine: Record "Posted Expense Report Line"; PostedExpReportHeader: Record "Posted Expense Report Header"; GenJournalLine: Record "Gen. Journal Line"): Integer var ProjectJournalLine: Record "Job Journal Line"; @@ -685,19 +699,6 @@ codeunit 6987 "Expense Report-Post" GenJournalLine.Validate("Gen. Posting Type", GenJournalLine."Gen. Posting Type"::Purchase); end; - local procedure ClearVATInformationOnGenJnlLine(var GenJournalLine: Record "Gen. Journal Line") - begin - GenJournalLine."Bill-to/Pay-to No." := ''; - GenJournalLine."VAT Registration No." := ''; - GenJournalLine."VAT Posting" := GenJournalLine."VAT Posting"::"Automatic VAT Entry"; - GenJournalLine."VAT %" := 0; - GenJournalLine."VAT Amount" := 0; - GenJournalLine."VAT Amount (LCY)" := 0; - GenJournalLine.Validate("VAT Prod. Posting Group", ''); - GenJournalLine.Validate("VAT Bus. Posting Group", ''); - GenJournalLine."Gen. Posting Type" := GenJournalLine."Gen. Posting Type"::" "; - end; - local procedure CreateGenJournalLine(var GenJournalLine: Record "Gen. Journal Line"; ExpenseReportHeader: Record "Expense Report Header"; PostedExpReportLine: Record "Posted Expense Report Line") begin GenJournalLine.Init(); @@ -725,7 +726,6 @@ codeunit 6987 "Expense Report-Post" GenJournalLine.Validate("Document Type", GenJournalLine."Document Type"::Invoice); GenJournalLine.Validate("Document No.", PostedExpReportLine."Document No."); GenJournalLine.Validate("Expense User No.", ExpenseReportHeader."Expense User No."); - GenJournalLine.Validate(Description, ExpenseReportLine.UpdatePostingDescription()); GenJournalLine.Validate("Keep Description", true); // Use spec-level expense category/subcategory when present; fall back to parent line. @@ -736,10 +736,19 @@ codeunit 6987 "Expense Report-Post" GenJournalLine.Validate("Expense Category", ExpenseReportLine."Expense Category"); GenJournalLine.Validate("Expense Subcategory Code", ExpenseReportLine."Expense Subcategory Code"); end; + GenJournalLine.Validate( + Description, + ExpenseReportLine.UpdatePostingDescription(GenJournalLine."Expense Category", GenJournalLine."Expense Subcategory Code")); + + GenJournalLine.Validate("Currency Code", ExpenseReportHeader."Reimbursement Currency Code"); + GenJournalLine.Validate("Source Currency Code", ExpenseReportHeader."Reimbursement Currency Code"); + GenJournalLine."Currency Factor" := ExpenseReportHeader."Reimbursement Currency Factor"; - // Amounts come directly from the spec row — base (net) + VAT as captured on the receipt. + // Amounts come directly from the spec row in reimbursement currency and LCY. GenJournalLine."Gen. Posting Type" := GenJournalLine."Gen. Posting Type"::Purchase; - GenJournalLine.Validate("Amount", ExpenseReportLineVATSpec."VAT Base Amount (LCY)"); + GenJournalLine.Amount := ExpenseReportLineVATSpec."VAT Base Amount (RCY)"; + GenJournalLine."Amount (LCY)" := ExpenseReportLineVATSpec."VAT Base Amount (LCY)"; + GenJournalLine."Source Currency Amount" := GenJournalLine.Amount; if ExpenseReportLineVATSpec."Reclaim Status" = ExpenseReportLineVATSpec."Reclaim Status"::"Approved" then begin GenJournalLine."VAT Bus. Posting Group" := ExpenseReportLineVATSpec."VAT Bus. Posting Group"; @@ -747,21 +756,24 @@ codeunit 6987 "Expense Report-Post" GenJournalLine."VAT Posting" := GenJournalLine."VAT Posting"::"Manual VAT Entry"; GenJournalLine."VAT Calculation Type" := ExpenseReportLine."VAT Calculation Type"; GenJournalLine."VAT %" := ExpenseReportLineVATSpec."VAT %"; - GenJournalLine."VAT Base Amount" := ExpenseReportLineVATSpec."VAT Base Amount (LCY)"; + GenJournalLine."VAT Base Amount" := ExpenseReportLineVATSpec."VAT Base Amount (RCY)"; GenJournalLine."VAT Base Amount (LCY)" := ExpenseReportLineVATSpec."VAT Base Amount (LCY)"; - GenJournalLine."VAT Amount" := ExpenseReportLineVATSpec."VAT Amount (LCY)"; + GenJournalLine."VAT Amount" := ExpenseReportLineVATSpec."VAT Amount (RCY)"; GenJournalLine."VAT Amount (LCY)" := ExpenseReportLineVATSpec."VAT Amount (LCY)"; + GenJournalLine."Source Curr. VAT Base Amount" := ExpenseReportLineVATSpec."VAT Base Amount (RCY)"; + GenJournalLine."Source Curr. VAT Amount" := ExpenseReportLineVATSpec."VAT Amount (RCY)"; // VAT Reclaim % is used to route the non-deductible portion of VAT to the correct accounts via VAT Posting Setup. - if (ExpenseReportLineVATSpec."Reclaim %" <> 100) and (ExpenseReportLineVATSpec."VAT Amount" <> 0) then begin + if (ExpenseReportLineVATSpec."Reclaim %" <> 100) and (ExpenseReportLineVATSpec."VAT Amount (LCY)" <> 0) then begin VATSetup.Get(); VATSetup.TestField("Non-Deductible VAT Is Enabled"); GenJournalLine.Validate("Non-Deductible VAT %", 100 - ExpenseReportLineVATSpec."Reclaim %"); end; end else begin // VAT is not reclaimable: include VAT in the expense amount (gross) and do not create a VAT entry. - GenJournalLine.Amount := ExpenseReportLineVATSpec."VAT Base Amount (LCY)" + ExpenseReportLineVATSpec."VAT Amount (LCY)"; - GenJournalLine."Amount (LCY)" := GenJournalLine.Amount; + GenJournalLine.Amount := ExpenseReportLineVATSpec."VAT Base Amount (RCY)" + ExpenseReportLineVATSpec."VAT Amount (RCY)"; + GenJournalLine."Amount (LCY)" := ExpenseReportLineVATSpec."VAT Base Amount (LCY)" + ExpenseReportLineVATSpec."VAT Amount (LCY)"; + GenJournalLine."Source Currency Amount" := GenJournalLine.Amount; GenJournalLine."VAT Posting" := GenJournalLine."VAT Posting"::"Automatic VAT Entry"; GenJournalLine."VAT %" := 0; GenJournalLine."VAT Amount" := 0; @@ -972,15 +984,23 @@ codeunit 6987 "Expense Report-Post" ReimbursementCurrency."Amount Rounding Precision"); end; - local procedure PostRoundingDifferenceOnCurrency(GenJournalLine: Record "Gen. Journal Line"; ExpenseReportLine: Record "Expense Report Line"; AmountToPost: Decimal) + local procedure PostRoundingDifferenceOnCurrency(ExpenseReportHeader: Record "Expense Report Header"; ExpenseReportLine: Record "Expense Report Line"; AmountToPost: Decimal; AmountToPostLCY: Decimal) var ExpenseCategory: Record "Expense Category"; ExpensePostingGroup: Record "Expense Posting Group"; + GenJournalLine: Record "Gen. Journal Line"; AccNo: Code[20]; + AmountForAccountSelection: Decimal; begin + if (AmountToPost = 0) and (AmountToPostLCY = 0) then + exit; + ExpenseCategory.Get(ExpenseReportLine."Expense Category"); ExpensePostingGroup.Get(ExpenseCategory."Posting Group"); - if AmountToPost > 0 then begin + AmountForAccountSelection := AmountToPostLCY; + if AmountForAccountSelection = 0 then + AmountForAccountSelection := AmountToPost; + if AmountForAccountSelection > 0 then begin ExpensePostingGroup.TestField("Debit Rounding Account"); AccNo := ExpensePostingGroup."Debit Rounding Account"; end else begin @@ -988,13 +1008,25 @@ codeunit 6987 "Expense Report-Post" AccNo := ExpensePostingGroup."Credit Rounding Account"; end; - GenJournalLine."Account Type" := GenJournalLine."Account Type"::"G/L Account"; + GenJournalLine.Init(); + GenJournalLine.Validate("Posting Date", ExpenseReportHeader."Posting Date"); + GenJournalLine.Validate("Document Type", GenJournalLine."Document Type"::Invoice); + GenJournalLine.Validate("Document No.", PostedExpenseReportHeader."No."); + GenJournalLine.Validate("Expense User No.", ExpenseReportHeader."Expense User No."); + GenJournalLine.Validate("Expense Category", ExpenseReportLine."Expense Category"); + GenJournalLine.Validate("Expense Subcategory Code", ExpenseReportLine."Expense Subcategory Code"); + GenJournalLine.Validate(Description, ExpenseReportLine.UpdatePostingDescription()); + GenJournalLine.Validate("Keep Description", true); + GenJournalLine.Validate("Currency Code", ExpenseReportHeader."Reimbursement Currency Code"); + GenJournalLine.Validate("Source Currency Code", ExpenseReportHeader."Reimbursement Currency Code"); + GenJournalLine."Currency Factor" := ExpenseReportHeader."Reimbursement Currency Factor"; + GenJournalLine.Validate("Account Type", GenJournalLine."Account Type"::"G/L Account"); GenJournalLine."Account No." := AccNo; GenJournalLine.Amount := AmountToPost; - GenJournalLine."Amount (LCY)" := AmountToPost; + GenJournalLine."Amount (LCY)" := AmountToPostLCY; GenJournalLine."Source Currency Amount" := AmountToPost; + SetupSourceCodeAndDimensions(GenJournalLine, ExpenseReportLine."Dimension Set ID"); GenJournalLine."System-Created Entry" := true; - ClearVATInformationOnGenJnlLine(GenJournalLine); GenJnlPostLine.RunWithCheck(GenJournalLine); end; diff --git a/src/Apps/W1/ExpenseAgent/app/src/ExpenseReport/Pages/ExpenseReportLineVATSpec.Page.al b/src/Apps/W1/ExpenseAgent/app/src/ExpenseReport/Pages/ExpenseReportLineVATSpec.Page.al index 4b826db7282..5f9ba1a5828 100644 --- a/src/Apps/W1/ExpenseAgent/app/src/ExpenseReport/Pages/ExpenseReportLineVATSpec.Page.al +++ b/src/Apps/W1/ExpenseAgent/app/src/ExpenseReport/Pages/ExpenseReportLineVATSpec.Page.al @@ -45,17 +45,22 @@ page 7081 "Expense Report Line VAT Spec." field("VAT %"; Rec."VAT %") { Caption = 'VAT %'; - Editable = false; + } + field(Amount; Rec.Amount) + { + Caption = 'Amount'; } field("VAT Base Amount"; Rec."VAT Base Amount") { Caption = 'VAT Base Amount'; - Editable = false; } field("VAT Amount"; Rec."VAT Amount") { Caption = 'VAT Amount'; - Editable = false; + } + field("Amount (LCY)"; Rec."Amount (LCY)") + { + Caption = 'Amount (LCY)'; } field("VAT Base Amount (LCY)"; Rec."VAT Base Amount (LCY)") { @@ -67,6 +72,21 @@ page 7081 "Expense Report Line VAT Spec." Caption = 'VAT Amount (LCY)'; Editable = false; } + field("Amount (RCY)"; Rec."Amount (RCY)") + { + Caption = 'Amount (RCY)'; + Editable = false; + } + field("VAT Base Amount (RCY)"; Rec."VAT Base Amount (RCY)") + { + Caption = 'VAT Base Amount (RCY)'; + Editable = false; + } + field("VAT Amount (RCY)"; Rec."VAT Amount (RCY)") + { + Caption = 'VAT Amount (RCY)'; + Editable = false; + } field(Reclaimable; Rec.Reclaimable) { Caption = 'Reclaimable'; @@ -85,6 +105,11 @@ page 7081 "Expense Report Line VAT Spec." Caption = 'Reclaim VAT Amount (LCY)'; Editable = false; } + field("Reclaim VAT Amount (RCY)"; Rec."Reclaim VAT Amount (RCY)") + { + Caption = 'Reclaim VAT Amount (RCY)'; + Editable = false; + } field("Reclaim Reason"; Rec."Reclaim Reason") { Caption = 'Reclaim Reason'; diff --git a/src/Apps/W1/ExpenseAgent/app/src/ExpenseReport/Pages/ExpenseReportVATSpec.Page.al b/src/Apps/W1/ExpenseAgent/app/src/ExpenseReport/Pages/ExpenseReportVATSpec.Page.al index ef957a5658e..3b8cd3b6820 100644 --- a/src/Apps/W1/ExpenseAgent/app/src/ExpenseReport/Pages/ExpenseReportVATSpec.Page.al +++ b/src/Apps/W1/ExpenseAgent/app/src/ExpenseReport/Pages/ExpenseReportVATSpec.Page.al @@ -40,6 +40,24 @@ page 7096 "Expense Report VAT Spec." Caption = 'VAT Amount'; ToolTip = 'Specifies the VAT amount for this rate.'; } + field("Amount (RCY)"; Rec."Amount (RCY)") + { + Caption = 'Amount (RCY)'; + ToolTip = 'Specifies the total amount for this rate in reimbursement currency.'; + Visible = ShowRCYFields; + } + field("VAT Base Amount (RCY)"; Rec."VAT Base Amount (RCY)") + { + Caption = 'VAT Base Amount (RCY)'; + ToolTip = 'Specifies the net amount this VAT rate applies to in reimbursement currency.'; + Visible = ShowRCYFields; + } + field("VAT Amount (RCY)"; Rec."VAT Amount (RCY)") + { + Caption = 'VAT Amount (RCY)'; + ToolTip = 'Specifies the VAT amount for this rate in reimbursement currency.'; + Visible = ShowRCYFields; + } field(Reclaimable; Rec.Reclaimable) { Caption = 'Reclaimable'; @@ -60,6 +78,12 @@ page 7096 "Expense Report VAT Spec." Caption = 'Reclaim VAT Amount (LCY)'; ToolTip = 'Specifies the reclaimable VAT amount for this row.'; } + field("Reclaim VAT Amount (RCY)"; Rec."Reclaim VAT Amount (RCY)") + { + Caption = 'Reclaim VAT Amount (RCY)'; + ToolTip = 'Specifies the reclaimable VAT amount for this row in reimbursement currency.'; + Visible = ShowRCYFields; + } field("Reclaim Status"; Rec."Reclaim Status") { Caption = 'Reclaim Status'; @@ -68,4 +92,18 @@ page 7096 "Expense Report VAT Spec." } } } + + trigger OnOpenPage() + var + ExpenseReportHeader: Record "Expense Report Header"; + begin + if Rec.GetFilter("Document No.") = '' then + exit; + + if ExpenseReportHeader.Get(Rec.GetRangeMin("Document No.")) then + ShowRCYFields := ExpenseReportHeader."Reimbursement Currency Code" <> ''; + end; + + var + ShowRCYFields: Boolean; } diff --git a/src/Apps/W1/ExpenseAgent/app/src/ExpenseReport/Pages/PostedExpenseReportVATSpec.Page.al b/src/Apps/W1/ExpenseAgent/app/src/ExpenseReport/Pages/PostedExpenseReportVATSpec.Page.al index 0d264ec3943..328c3bf2fc9 100644 --- a/src/Apps/W1/ExpenseAgent/app/src/ExpenseReport/Pages/PostedExpenseReportVATSpec.Page.al +++ b/src/Apps/W1/ExpenseAgent/app/src/ExpenseReport/Pages/PostedExpenseReportVATSpec.Page.al @@ -50,6 +50,24 @@ page 7093 "Posted Expense Report VAT Spec" Caption = 'VAT Amount'; ToolTip = 'Specifies the VAT amount for this rate.'; } + field("Amount (RCY)"; Rec."Amount (RCY)") + { + Caption = 'Amount (RCY)'; + ToolTip = 'Specifies the total amount for this rate in reimbursement currency.'; + Visible = ShowRCYFields; + } + field("VAT Base Amount (RCY)"; Rec."VAT Base Amount (RCY)") + { + Caption = 'VAT Base Amount (RCY)'; + ToolTip = 'Specifies the net amount this VAT rate applies to in reimbursement currency.'; + Visible = ShowRCYFields; + } + field("VAT Amount (RCY)"; Rec."VAT Amount (RCY)") + { + Caption = 'VAT Amount (RCY)'; + ToolTip = 'Specifies the VAT amount for this rate in reimbursement currency.'; + Visible = ShowRCYFields; + } field(Reclaimable; Rec.Reclaimable) { Caption = 'Reclaimable'; @@ -68,6 +86,12 @@ page 7093 "Posted Expense Report VAT Spec" { Caption = 'Reclaim VAT Amount (LCY)'; } + field("Reclaim VAT Amount (RCY)"; Rec."Reclaim VAT Amount (RCY)") + { + Caption = 'Reclaim VAT Amount (RCY)'; + ToolTip = 'Specifies the reclaimable VAT amount for this row in reimbursement currency.'; + Visible = ShowRCYFields; + } field("Reclaim Status"; Rec."Reclaim Status") { Caption = 'Reclaim Status'; @@ -76,4 +100,18 @@ page 7093 "Posted Expense Report VAT Spec" } } } + + trigger OnOpenPage() + var + PostedExpenseReportHeader: Record "Posted Expense Report Header"; + begin + if Rec.GetFilter("Expense Report No.") = '' then + exit; + + if PostedExpenseReportHeader.Get(Rec.GetRangeMin("Expense Report No.")) then + ShowRCYFields := PostedExpenseReportHeader."Reimbursement Currency Code" <> ''; + end; + + var + ShowRCYFields: Boolean; } 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..41d5722d37c 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 @@ -685,6 +685,21 @@ table 6906 "Expense Report Header" UpdateSpendRequestOnReportLine(ExpenseReportLine); end; until ExpenseReportLine.Next() = 0; + + if CalledFromFieldName in [Rec.FieldName("Reimbursement Currency Code"), Rec.FieldName("Reimbursement Currency Factor"), Rec.FieldName("Posting Date")] then + UpdateVATSpecReimbursementAmounts(); + end; + + local procedure UpdateVATSpecReimbursementAmounts() + var + ExpenseReportLineVATSpec: Record "Expense Report Line VAT Spec."; + begin + ExpenseReportLineVATSpec.SetRange("Document No.", "No."); + if ExpenseReportLineVATSpec.FindSet(true) then + repeat + ExpenseReportLineVATSpec.UpdateReimbursementAmounts(Rec); + ExpenseReportLineVATSpec.Modify(); + until ExpenseReportLineVATSpec.Next() = 0; end; local procedure UpdateCurrFactorOnReportLine(var ExpenseReportLine: Record "Expense Report Line") diff --git a/src/Apps/W1/ExpenseAgent/app/src/ExpenseReport/Tables/ExpenseReportLine.Table.al b/src/Apps/W1/ExpenseAgent/app/src/ExpenseReport/Tables/ExpenseReportLine.Table.al index 62bacc1252e..09e8f9b3210 100644 --- a/src/Apps/W1/ExpenseAgent/app/src/ExpenseReport/Tables/ExpenseReportLine.Table.al +++ b/src/Apps/W1/ExpenseAgent/app/src/ExpenseReport/Tables/ExpenseReportLine.Table.al @@ -1389,14 +1389,33 @@ table 6907 "Expense Report Line" end; procedure UpdatePostingDescription(): Text[100] + begin + exit(UpdatePostingDescription("Expense Category", "Expense Subcategory Code")); + end; + + procedure UpdatePostingDescription(ExpenseCategoryCode: Code[20]; ExpenseSubcategoryCode: Code[20]): Text[100] var ExpenseSubcategory: Record "Expense Subcategory"; - begin - if "Expense Subcategory Code" <> '' then begin - ExpenseSubcategory.Get("Expense Category", "Expense Subcategory Code"); - exit(CopyStr(Description + ' - ' + ExpenseSubcategory."Posting Description", 1, 100)); + BaseDescription: Text[100]; + PostingDescriptionSuffix: Text; + begin + BaseDescription := Description; + if ("Expense Subcategory Code" <> '') and + ExpenseSubcategory.Get("Expense Category", "Expense Subcategory Code") and + (ExpenseSubcategory."Posting Description" <> '') + then begin + PostingDescriptionSuffix := ' / ' + ExpenseSubcategory."Posting Description"; + if BaseDescription.EndsWith(PostingDescriptionSuffix) then + BaseDescription := CopyStr(BaseDescription, 1, StrLen(BaseDescription) - StrLen(PostingDescriptionSuffix)); end; - exit(CopyStr(Description, 1, 100)); + + if (ExpenseSubcategoryCode = '') or + (not ExpenseSubcategory.Get(ExpenseCategoryCode, ExpenseSubcategoryCode)) or + (ExpenseSubcategory."Posting Description" = '') + then + exit(BaseDescription); + + exit(CopyStr(BaseDescription + ' / ' + ExpenseSubcategory."Posting Description", 1, 100)); end; local procedure InitDefaultDimensionSources(var DefaultDimSource: List of [Dictionary of [Integer, Code[20]]]; FieldNo: Integer) diff --git a/src/Apps/W1/ExpenseAgent/app/src/ExpenseReport/Tables/ExpenseReportLineVATSpec.Table.al b/src/Apps/W1/ExpenseAgent/app/src/ExpenseReport/Tables/ExpenseReportLineVATSpec.Table.al index 5fb1d603f8f..b6b75b7791f 100644 --- a/src/Apps/W1/ExpenseAgent/app/src/ExpenseReport/Tables/ExpenseReportLineVATSpec.Table.al +++ b/src/Apps/W1/ExpenseAgent/app/src/ExpenseReport/Tables/ExpenseReportLineVATSpec.Table.al @@ -52,6 +52,17 @@ table 6922 "Expense Report Line VAT Spec." MinValue = 0; MaxValue = 100; ToolTip = 'Specifies the VAT rate.'; + + trigger OnValidate() + begin + InitializeCurrency(); + "VAT Amount" := Round(Amount * "VAT %" / (100 + "VAT %"), Currency."Amount Rounding Precision", Currency.VATRoundingDirection()); + "VAT Base Amount" := Round(Amount - "VAT Amount", Currency."Amount Rounding Precision"); + "VAT Difference" := 0; + "VAT Amount (LCY)" := CalcVATAmountLCY(); + "VAT Base Amount (LCY)" := "Amount (LCY)" - "VAT Amount (LCY)"; + UpdateReimbursementAmounts(); + end; } field(11; "VAT Base Amount"; Decimal) { @@ -73,6 +84,11 @@ table 6922 "Expense Report Line VAT Spec." AutoFormatType = 1; Caption = 'Amount'; ToolTip = 'Specifies the total amount (including VAT) that the expense line consists of.'; + + trigger OnValidate() + begin + ValidateAmount(); + end; } /// /// Calculated difference between expected and actual VAT amount allowing for VAT tolerance variations. @@ -162,12 +178,47 @@ table 6922 "Expense Report Line VAT Spec." Caption = 'Expense Category'; TableRelation = "Expense Category".Code; ToolTip = 'Specifies the expense category associated with this VAT specification line, used to identify the type of expense for reporting and VAT reclaim purposes.'; + + trigger OnValidate() + var + ExpenseCategory: Record "Expense Category"; + begin + if "Expense Category" = '' then + exit; + if "Expense Subcategory" <> '' then + exit; + if ExpenseCategory.Get("Expense Category") then begin + "VAT Prod. Posting Group" := ExpenseCategory."VAT Prod. Posting Group"; + "VAT %" := ExpenseCategory."Default VAT %"; + Validate("VAT %"); + end; + end; } field(28; "Expense Subcategory"; Code[20]) { Caption = 'Expense Subcategory'; TableRelation = "Expense Subcategory".Code where("Expense Category Code" = field("Expense Category")); ToolTip = 'Specifies the expense subcategory associated with this VAT specification line, providing a more detailed classification within the expense category.'; + + trigger OnValidate() + var + ExpenseCategory: Record "Expense Category"; + ExpenseSubcategory: Record "Expense Subcategory"; + begin + if "Expense Subcategory" <> '' then begin + if ExpenseSubcategory.Get("Expense Category", "Expense Subcategory") then begin + "VAT Prod. Posting Group" := ExpenseSubcategory."VAT Prod. Posting Group"; + "VAT %" := ExpenseSubcategory."Default VAT %"; + Validate("VAT %"); + end; + end else + if "Expense Category" <> '' then + if ExpenseCategory.Get("Expense Category") then begin + "VAT Prod. Posting Group" := ExpenseCategory."VAT Prod. Posting Group"; + "VAT %" := ExpenseCategory."Default VAT %"; + Validate("VAT %"); + end; + end; } field(30; Reclaimable; Boolean) { @@ -192,22 +243,11 @@ table 6922 "Expense Report Line VAT Spec." ToolTip = 'Specifies the reclaim percentage for partial deductibility.'; trigger OnValidate() - var - Currency: Record Currency; - GLSetup: Record "General Ledger Setup"; - CurrAmountRoundingPrecision: Decimal; - LCYAmountRoundingPrecision: Decimal; begin if "Reclaim %" <> xRec."Reclaim %" then "Reclaim Status" := "Reclaim Status"::"Pending"; - GLSetup.Get(); - LCYAmountRoundingPrecision := GLSetup."Amount Rounding Precision"; - CurrAmountRoundingPrecision := LCYAmountRoundingPrecision; - if ("Currency Code" <> '') and Currency.Get("Currency Code") then - CurrAmountRoundingPrecision := Currency."Amount Rounding Precision"; - "Reclaim VAT Amount" := Round("VAT Amount" * "Reclaim %" / 100, CurrAmountRoundingPrecision); - "Reclaim VAT Amount (LCY)" := Round("VAT Amount (LCY)" * "Reclaim %" / 100, LCYAmountRoundingPrecision); + UpdateReclaimAmounts(); end; } field(32; "Reclaim Reason"; Text[250]) @@ -304,6 +344,41 @@ table 6922 "Expense Report Line VAT Spec." Rec."Reclaim Approved At" := CurrentDateTime(); end; } + /// + /// VAT amount converted to local currency for accounting and reporting purposes. + /// + field(50; "VAT Base Amount (RCY)"; Decimal) + { + AutoFormatType = 1; + AutoFormatExpression = GetReimbursementCurrencyCode(); + Caption = 'VAT Base Amount (RCY)'; + Editable = false; + ToolTip = 'Specifies the VAT base amount in reimbursement currency for this rate.'; + } + field(51; "VAT Amount (RCY)"; Decimal) + { + AutoFormatType = 1; + AutoFormatExpression = GetReimbursementCurrencyCode(); + Caption = 'VAT Amount (RCY)'; + Editable = false; + ToolTip = 'Specifies the VAT amount in reimbursement currency for this rate.'; + } + field(52; "Amount (RCY)"; Decimal) + { + AutoFormatType = 1; + AutoFormatExpression = GetReimbursementCurrencyCode(); + Caption = 'Amount (RCY)'; + Editable = false; + ToolTip = 'Specifies the total amount in reimbursement currency for this rate.'; + } + field(53; "Reclaim VAT Amount (RCY)"; Decimal) + { + AutoFormatType = 1; + AutoFormatExpression = GetReimbursementCurrencyCode(); + Caption = 'Reclaim VAT Amount (RCY)'; + Editable = false; + ToolTip = 'Specifies the reclaim VAT amount in reimbursement currency for this rate.'; + } } keys @@ -315,6 +390,148 @@ table 6922 "Expense Report Line VAT Spec." key(Reclaim; "Document No.", Reclaimable, "Reclaim Status") { } } + var + Currency: Record Currency; + CurrencyExchangeRate: Record "Currency Exchange Rate"; + ExpenseReportHeader: Record "Expense Report Header"; + ExpenseReportLine: Record "Expense Report Line"; + + local procedure CalcVATAmountLCY(): Decimal + var + LCYCurrency: Record Currency; + VATAmountLCY: Decimal; + begin + if "Currency Code" = '' then + exit("VAT Amount"); + + LCYCurrency.InitRoundingPrecision(); + InitializeCurrency(); + + "VAT Difference" := + "VAT Amount" - + Round(Amount * "VAT %" / (100 + "VAT %"), Currency."Amount Rounding Precision", Currency.VATRoundingDirection()); + + if "VAT Difference" = 0 then + VATAmountLCY := Round("Amount (LCY)" * "VAT %" / (100 + "VAT %"), LCYCurrency."Amount Rounding Precision", LCYCurrency.VATRoundingDirection()) + else begin + GetExpenseReportLine(); + VATAmountLCY := + Round( + CurrencyExchangeRate.ExchangeAmtFCYToLCY(ExpenseReportLine."Expense Date", "Currency Code", "VAT Amount", "Currency Factor"), + LCYCurrency."Amount Rounding Precision", LCYCurrency.VATRoundingDirection()); + end; + + exit(VATAmountLCY); + end; + + local procedure GetExpenseReportHeader() + begin + if "Document No." <> ExpenseReportHeader."No." then + ExpenseReportHeader.Get("Document No."); + end; + + local procedure GetExpenseReportLine() + begin + if ("Document No." <> ExpenseReportLine."Document No.") or ("Document Line No." <> ExpenseReportLine."Line No.") then + ExpenseReportLine.Get("Document No.", "Document Line No."); + end; + + local procedure GetReimbursementCurrencyCode(): Code[20] + begin + GetExpenseReportHeader(); + exit(ExpenseReportHeader."Reimbursement Currency Code"); + end; + + local procedure InitializeCurrency() + begin + if "Currency Code" = '' then begin + Clear(Currency); + Currency.InitRoundingPrecision(); + end else + if "Currency Code" <> Currency.Code then begin + Currency.Get("Currency Code"); + Currency.TestField("Amount Rounding Precision"); + end; + end; + + local procedure UpdateReclaimAmounts() + begin + GetExpenseReportHeader(); + UpdateReclaimAmounts(ExpenseReportHeader."Reimbursement Currency Code"); + end; + + local procedure UpdateReclaimAmounts(ReimbursementCurrencyCode: Code[10]) + var + GLSetup: Record "General Ledger Setup"; + ReimbursementCurrency: Record Currency; + CurrencyAmountRoundingPrecision: Decimal; + LCYAmountRoundingPrecision: Decimal; + ReimbursementAmountRoundingPrecision: Decimal; + begin + GLSetup.Get(); + LCYAmountRoundingPrecision := GLSetup."Amount Rounding Precision"; + CurrencyAmountRoundingPrecision := LCYAmountRoundingPrecision; + if ("Currency Code" <> '') and Currency.Get("Currency Code") then + CurrencyAmountRoundingPrecision := Currency."Amount Rounding Precision"; + + ReimbursementCurrency.Initialize(ReimbursementCurrencyCode); + ReimbursementAmountRoundingPrecision := ReimbursementCurrency."Amount Rounding Precision"; + + "Reclaim VAT Amount" := Round("VAT Amount" * "Reclaim %" / 100, CurrencyAmountRoundingPrecision); + "Reclaim VAT Amount (LCY)" := Round("VAT Amount (LCY)" * "Reclaim %" / 100, LCYAmountRoundingPrecision); + "Reclaim VAT Amount (RCY)" := Round("VAT Amount (RCY)" * "Reclaim %" / 100, ReimbursementAmountRoundingPrecision); + end; + + procedure UpdateReimbursementAmounts() + begin + GetExpenseReportHeader(); + UpdateReimbursementAmounts(ExpenseReportHeader); + end; + + procedure UpdateReimbursementAmounts(NewExpenseReportHeader: Record "Expense Report Header") + var + ReimbursementCurrency: Record Currency; + begin + ReimbursementCurrency.Initialize(NewExpenseReportHeader."Reimbursement Currency Code"); + + if NewExpenseReportHeader."Reimbursement Currency Code" = '' then begin + "VAT Base Amount (RCY)" := "VAT Base Amount (LCY)"; + "VAT Amount (RCY)" := "VAT Amount (LCY)"; + "Amount (RCY)" := "Amount (LCY)"; + end else begin + "VAT Base Amount (RCY)" := + Round( + CurrencyExchangeRate.ExchangeAmtLCYToFCY( + NewExpenseReportHeader."Posting Date", NewExpenseReportHeader."Reimbursement Currency Code", + "VAT Base Amount (LCY)", NewExpenseReportHeader."Reimbursement Currency Factor"), + ReimbursementCurrency."Amount Rounding Precision"); + "VAT Amount (RCY)" := + Round( + CurrencyExchangeRate.ExchangeAmtLCYToFCY( + NewExpenseReportHeader."Posting Date", NewExpenseReportHeader."Reimbursement Currency Code", + "VAT Amount (LCY)", NewExpenseReportHeader."Reimbursement Currency Factor"), + ReimbursementCurrency."Amount Rounding Precision"); + "Amount (RCY)" := "VAT Base Amount (RCY)" + "VAT Amount (RCY)"; + end; + + UpdateReclaimAmounts(NewExpenseReportHeader."Reimbursement Currency Code"); + end; + + local procedure ValidateAmount() + begin + InitializeCurrency(); + if "Currency Code" = '' then + "Amount (LCY)" := Amount + else begin + GetExpenseReportLine(); + "Amount (LCY)" := Round( + CurrencyExchangeRate.ExchangeAmtFCYToLCY(ExpenseReportLine."Expense Date", "Currency Code", Amount, "Currency Factor")); + end; + + Amount := Round(Amount, Currency."Amount Rounding Precision"); + Validate("VAT %"); + end; + /// Stores a UTF-8 reclaim justification text into the blob. procedure SetJustification(NewText: Text) var diff --git a/src/Apps/W1/ExpenseAgent/app/src/ExpenseReport/Tables/PostedExpRepLineVATSpec.Table.al b/src/Apps/W1/ExpenseAgent/app/src/ExpenseReport/Tables/PostedExpRepLineVATSpec.Table.al index bc49985be51..5533f776d22 100644 --- a/src/Apps/W1/ExpenseAgent/app/src/ExpenseReport/Tables/PostedExpRepLineVATSpec.Table.al +++ b/src/Apps/W1/ExpenseAgent/app/src/ExpenseReport/Tables/PostedExpRepLineVATSpec.Table.al @@ -235,6 +235,38 @@ table 6934 "Posted Exp. Rep. Line VAT Spec" Editable = false; ToolTip = 'Specifies whether the VAT reclaim for this row is pending, approved, or rejected.'; } + field(50; "VAT Base Amount (RCY)"; Decimal) + { + AutoFormatType = 1; + AutoFormatExpression = GetReimbursementCurrencyCode(); + Caption = 'VAT Base Amount (RCY)'; + Editable = false; + ToolTip = 'Specifies the VAT base amount in reimbursement currency for this rate.'; + } + field(51; "VAT Amount (RCY)"; Decimal) + { + AutoFormatType = 1; + AutoFormatExpression = GetReimbursementCurrencyCode(); + Caption = 'VAT Amount (RCY)'; + Editable = false; + ToolTip = 'Specifies the VAT amount in reimbursement currency for this rate.'; + } + field(52; "Amount (RCY)"; Decimal) + { + AutoFormatType = 1; + AutoFormatExpression = GetReimbursementCurrencyCode(); + Caption = 'Amount (RCY)'; + Editable = false; + ToolTip = 'Specifies the total amount in reimbursement currency for this rate.'; + } + field(53; "Reclaim VAT Amount (RCY)"; Decimal) + { + AutoFormatType = 1; + AutoFormatExpression = GetReimbursementCurrencyCode(); + Caption = 'Reclaim VAT Amount (RCY)'; + Editable = false; + ToolTip = 'Specifies the reclaim VAT amount in reimbursement currency for this rate.'; + } } keys @@ -248,6 +280,14 @@ table 6934 "Posted Exp. Rep. Line VAT Spec" } } + local procedure GetReimbursementCurrencyCode(): Code[20] + var + PostedExpenseReportHeader: Record "Posted Expense Report Header"; + begin + if PostedExpenseReportHeader.Get("Expense Report No.") then + exit(PostedExpenseReportHeader."Reimbursement Currency Code"); + end; + /// Stores a UTF-8 reclaim justification text into the blob. procedure SetJustification(NewText: Text) var diff --git a/src/Apps/W1/ExpenseAgent/test/src/ExpensePostingVATTest.Codeunit.al b/src/Apps/W1/ExpenseAgent/test/src/ExpensePostingVATTest.Codeunit.al index c1c2f3bf16e..a804a110e6e 100644 --- a/src/Apps/W1/ExpenseAgent/test/src/ExpensePostingVATTest.Codeunit.al +++ b/src/Apps/W1/ExpenseAgent/test/src/ExpensePostingVATTest.Codeunit.al @@ -5,6 +5,7 @@ namespace Microsoft.Test.ExpenseAgent; using Microsoft.ExpenseAgent; +using Microsoft.Finance.Currency; using Microsoft.Finance.GeneralLedger.Ledger; using Microsoft.Finance.VAT.Ledger; using Microsoft.Finance.VAT.Setup; @@ -25,6 +26,7 @@ codeunit 148330 "Expense Posting VAT Test" LibraryTestInitialize: Codeunit "Library - Test Initialize"; LibraryUtility: Codeunit "Library - Utility"; IsInitialized: Boolean; + NotApprovedForVATReclaimCategoryErr: Label 'VAT Reclaim Status is not set for Line with Expense Category %1.', Comment = '%1 = Expense Category'; NotApprovedForVATReclaimErr: Label 'VAT Reclaim Status is not set for Line with Expense Category %1 and Expense Subcategory %2.', Comment = '%1 = Expense Category, %2 = Expense Subcategory'; [Test] @@ -402,6 +404,255 @@ codeunit 148330 "Expense Posting VAT Test" Assert.RecordCount(PostedExpenseReportHeader, 0); end; + [Test] + [HandlerFunctions('ExpensesModalPageHandler,ConfirmHandler')] + procedure VATSpecWithoutSubcategoryPendingStatusBlocksPosting() + var + Expense: Record Expense; + ExpenseCategory: Record "Expense Category"; + ExpenseReportHeader: Record "Expense Report Header"; + ExpenseSubCategory: Record "Expense Subcategory"; + ExpenseUser: Record "Expense User"; + PostedExpenseReportHeader: Record "Posted Expense Report Header"; + VATPostingSetup: Record "VAT Posting Setup"; + CreateExpenseReport: Codeunit "Create Expense Report"; + ExpenseReportPost: Codeunit "Expense Report-Post"; + begin + // [SCENARIO] Posting is blocked when a category-only VAT specification remains pending. + Initialize(); + + // [GIVEN] A non-itemized expense with a category-level VAT specification. + LibraryExpense.CreateExpenseUser(ExpenseUser); + LibraryExpense.CreateExpenseCategory(ExpenseCategory, ExpenseCategory."Reimbursement Type"::"Employee Paid", ExpenseCategory."Expense Detail Required"::" "); + CreateSubcategoryWithVATRate(ExpenseSubCategory, ExpenseCategory.Code, 20, VATPostingSetup); + ExpenseCategory.Validate("VAT Prod. Posting Group", VATPostingSetup."VAT Prod. Posting Group"); + ExpenseCategory.Validate("Default VAT %", VATPostingSetup."VAT %"); + ExpenseCategory.Modify(true); + LibraryExpense.CreateExpense(Expense, ExpenseUser."No.", ExpenseCategory.Code, '', '', true, '', 120); + Expense.UpdateVATSpecification(Expense."No."); + ReleaseExpenseAndUpdateAccounts(Expense, ExpenseUser); + + LibraryExpense.CreateExpenseReport(ExpenseReportHeader, ExpenseUser."No.", '', Expense."VAT Bus. Posting Group"); + CreateExpenseReport.AddExpensesToReport(ExpenseReportHeader); + UpdateExpenseReportLinesWithVendorKeepingPending(ExpenseReportHeader); + ExpenseReportHeader.PerformManualRelease(); + + // [WHEN] Posting is attempted. + asserterror ExpenseReportPost.PostExpenseReport(ExpenseReportHeader); + + // [THEN] The error identifies the expense category without requiring a subcategory. + Assert.ExpectedError(StrSubstNo(NotApprovedForVATReclaimCategoryErr, ExpenseCategory.Code)); + PostedExpenseReportHeader.SetRange("Expense User No.", ExpenseUser."No."); + Assert.RecordCount(PostedExpenseReportHeader, 0); + end; + + [Test] + [HandlerFunctions('ExpensesModalPageHandler,ConfirmHandler')] + procedure ModifiedVATSpecIsRecalculatedAndPostedInReimbursementCurrency() + var + Currency: Record Currency; + CurrencyExchangeRate: Record "Currency Exchange Rate"; + Expense: Record Expense; + ExpenseUser: Record "Expense User"; + ExpenseCategory: Record "Expense Category"; + ExpenseSubCategory: array[4] of Record "Expense Subcategory"; + ExpenseReportHeader: Record "Expense Report Header"; + ExpenseReportLineVATSpec: Record "Expense Report Line VAT Spec."; + PostedExpenseReportHeader: Record "Posted Expense Report Header"; + PostedExpenseReportLineVATSpec: Record "Posted Exp. Rep. Line VAT Spec"; + VATPostingSetup: array[3] of Record "VAT Posting Setup"; + GLEntry: Record "G/L Entry"; + CreateExpenseReport: Codeunit "Create Expense Report"; + ExpenseReportPost: Codeunit "Expense Report-Post"; + CurrencyCode: Code[10]; + ExpectedVATBaseAmountRCY: Decimal; + ExpectedVATAmountRCY: Decimal; + begin + // [SCENARIO] A copied VAT specification can be changed and is posted in reimbursement currency. + Initialize(); + + // [GIVEN] An LCY expense for 110 with 10% VAT and an expense report in a foreign reimbursement currency. + CreateExpenseUserAndCategory(ExpenseUser, ExpenseCategory); + CreateSubcategoryWithVATRate(ExpenseSubCategory[1], ExpenseCategory.Code, 10, VATPostingSetup[1]); + CreateExpenseWithHotelItemizations(Expense, ExpenseUser, ExpenseCategory, ExpenseSubCategory, VATPostingSetup, 110, 0, 0, 0); + CurrencyCode := LibraryERM.CreateCurrencyWithExchangeRate(WorkDate(), 1, 2); + Currency.Get(CurrencyCode); + + LibraryExpense.CreateExpenseReport(ExpenseReportHeader, ExpenseUser."No.", CurrencyCode, Expense."VAT Bus. Posting Group"); + CreateExpenseReport.AddExpensesToReport(ExpenseReportHeader); + + // [WHEN] The VAT rate is changed to 20% on the copied specification. + ExpenseReportLineVATSpec.SetRange("Document No.", ExpenseReportHeader."No."); + ExpenseReportLineVATSpec.FindFirst(); + ExpenseReportLineVATSpec.Validate("VAT %", 20); + ExpenseReportLineVATSpec.Modify(true); + + // [THEN] Transaction, LCY, and reimbursement-currency amounts are recalculated from the gross amount. + ExpectedVATBaseAmountRCY := + Round( + CurrencyExchangeRate.ExchangeAmtLCYToFCY( + ExpenseReportHeader."Posting Date", CurrencyCode, ExpenseReportLineVATSpec."VAT Base Amount (LCY)", + ExpenseReportHeader."Reimbursement Currency Factor"), + Currency."Amount Rounding Precision"); + ExpectedVATAmountRCY := + Round( + CurrencyExchangeRate.ExchangeAmtLCYToFCY( + ExpenseReportHeader."Posting Date", CurrencyCode, ExpenseReportLineVATSpec."VAT Amount (LCY)", + ExpenseReportHeader."Reimbursement Currency Factor"), + Currency."Amount Rounding Precision"); + Assert.AreNearlyEqual(91.67, ExpenseReportLineVATSpec."VAT Base Amount", 0.01, 'VAT base amount must be recalculated.'); + Assert.AreNearlyEqual(18.33, ExpenseReportLineVATSpec."VAT Amount", 0.01, 'VAT amount must be recalculated.'); + Assert.AreNearlyEqual(ExpectedVATBaseAmountRCY, ExpenseReportLineVATSpec."VAT Base Amount (RCY)", 0.01, 'VAT base amount in reimbursement currency must be calculated from LCY.'); + Assert.AreNearlyEqual(ExpectedVATAmountRCY, ExpenseReportLineVATSpec."VAT Amount (RCY)", 0.01, 'VAT amount in reimbursement currency must be calculated from LCY.'); + Assert.AreNearlyEqual(ExpectedVATBaseAmountRCY + ExpectedVATAmountRCY, ExpenseReportLineVATSpec."Amount (RCY)", 0.01, 'Gross amount in reimbursement currency must equal base plus VAT.'); + + // [WHEN] The report is approved for reclaim and posted. + UpdateExpenseReportLinesWithVendor(ExpenseReportHeader); + ExpenseReportHeader.PerformManualRelease(); + ExpenseReportPost.PostExpenseReport(ExpenseReportHeader); + + // [THEN] The VAT specification journal line is posted with RCY as source currency and LCY as G/L amount. + FindPostedExpenseReport(PostedExpenseReportHeader, Expense); + PostedExpenseReportLineVATSpec.SetRange("Expense Report No.", PostedExpenseReportHeader."No."); + PostedExpenseReportLineVATSpec.SetFilter("Expense Report Line No.", '<>%1', 0); + PostedExpenseReportLineVATSpec.FindFirst(); + Assert.AreNearlyEqual(ExpectedVATBaseAmountRCY, PostedExpenseReportLineVATSpec."VAT Base Amount (RCY)", 0.01, 'Posted VAT base amount in reimbursement currency must be retained.'); + Assert.AreNearlyEqual(ExpectedVATAmountRCY, PostedExpenseReportLineVATSpec."VAT Amount (RCY)", 0.01, 'Posted VAT amount in reimbursement currency must be retained.'); + Assert.AreNearlyEqual(ExpectedVATBaseAmountRCY + ExpectedVATAmountRCY, PostedExpenseReportLineVATSpec."Amount (RCY)", 0.01, 'Posted gross amount in reimbursement currency must be retained.'); + Assert.AreNearlyEqual(ExpectedVATAmountRCY, PostedExpenseReportLineVATSpec."Reclaim VAT Amount (RCY)", 0.01, 'Posted reclaim VAT amount in reimbursement currency must be retained.'); + GLEntry.SetRange("Document No.", PostedExpenseReportHeader."No."); + GLEntry.SetRange("G/L Account No.", GetRefundableDebitAccount(ExpenseCategory.Code)); + GLEntry.SetRange("Source Currency Code", CurrencyCode); + GLEntry.SetRange(Description, CopyStr(Expense.Description + ' / ' + ExpenseSubCategory[1]."Posting Description", 1, MaxStrLen(GLEntry.Description))); + GLEntry.CalcSums(Amount, "Source Currency Amount", "Source Currency VAT Amount"); + Assert.AreNearlyEqual(ExpenseReportLineVATSpec."VAT Base Amount (LCY)", GLEntry.Amount, 0.01, 'Posted G/L amount must use the VAT specification LCY base.'); + Assert.AreNearlyEqual(ExpectedVATBaseAmountRCY, GLEntry."Source Currency Amount", 0.01, 'Posted source currency amount must use the VAT specification reimbursement amount.'); + Assert.AreNearlyEqual(ExpectedVATAmountRCY, GLEntry."Source Currency VAT Amount", 0.01, 'Posted source currency VAT amount must use the VAT specification reimbursement amount.'); + end; + + [Test] + [HandlerFunctions('ExpensesModalPageHandler,ConfirmHandler')] + procedure VATSpecPostingBalancesForeignReimbursementCurrency() + var + Expense: Record Expense; + ExpenseUser: Record "Expense User"; + ExpenseCategory: Record "Expense Category"; + ExpenseSubCategory: array[4] of Record "Expense Subcategory"; + ExpenseReportHeader: Record "Expense Report Header"; + ExpenseReportLineVATSpec: Record "Expense Report Line VAT Spec."; + PostedExpenseReportHeader: Record "Posted Expense Report Header"; + VATPostingSetup: array[3] of Record "VAT Posting Setup"; + GLEntry: Record "G/L Entry"; + CreateExpenseReport: Codeunit "Create Expense Report"; + ExpenseReportPost: Codeunit "Expense Report-Post"; + CurrencyCode: Code[10]; + begin + // [SCENARIO] Component rounding in a foreign reimbursement currency does not leave the G/L transaction out of balance. + Initialize(); + + // [GIVEN] A 200.02 LCY expense split into two VAT specs and an 8.56 reimbursement-currency exchange rate. + CreateExpenseUserAndCategory(ExpenseUser, ExpenseCategory); + CreateSubcategoryWithVATRate(ExpenseSubCategory[1], ExpenseCategory.Code, 20, VATPostingSetup[1]); + CreateSubcategoryWithVATRate(ExpenseSubCategory[2], ExpenseCategory.Code, 20, VATPostingSetup[1]); + CreateExpenseWithHotelItemizations(Expense, ExpenseUser, ExpenseCategory, ExpenseSubCategory, VATPostingSetup, 100.01, 100.01, 0, 0); + CurrencyCode := LibraryERM.CreateCurrencyWithExchangeRate(WorkDate(), 1, 8.56); + LibraryExpense.CreateExpenseReport(ExpenseReportHeader, ExpenseUser."No.", CurrencyCode, Expense."VAT Bus. Posting Group"); + CreateExpenseReport.AddExpensesToReport(ExpenseReportHeader); + ExpenseReportLineVATSpec.SetRange("Document No.", ExpenseReportHeader."No."); + ExpenseReportLineVATSpec.FindFirst(); + ExpenseReportLineVATSpec."VAT Base Amount (LCY)" += 0.01; + ExpenseReportLineVATSpec."VAT Base Amount (RCY)" += 0.08; + ExpenseReportLineVATSpec."Amount (RCY)" += 0.08; + ExpenseReportLineVATSpec.Modify(); + + // [WHEN] The report is approved and posted. + UpdateExpenseReportLinesWithVendor(ExpenseReportHeader); + ExpenseReportHeader.PerformManualRelease(); + ExpenseReportPost.PostExpenseReport(ExpenseReportHeader); + + // [THEN] The document balances in LCY and reimbursement currency. + FindPostedExpenseReport(PostedExpenseReportHeader, Expense); + GLEntry.SetRange("Document No.", PostedExpenseReportHeader."No."); + GLEntry.CalcSums(Amount, "Source Currency Amount"); + Assert.AreEqual(0, GLEntry.Amount, 'The posted G/L entries must balance in LCY.'); + Assert.AreEqual(0, GLEntry."Source Currency Amount", 'The posted G/L entries must balance in reimbursement currency.'); + end; + + [Test] + procedure UpdateVATSpecificationCreatesSpecForNonItemizedExpense() + var + Expense: Record Expense; + ExpenseCategory: Record "Expense Category"; + ExpenseSubCategory: Record "Expense Subcategory"; + ExpenseUser: Record "Expense User"; + ExpenseVATSpecification: Record "Expense VAT Specification"; + VATPostingSetup: Record "VAT Posting Setup"; + begin + // [SCENARIO] Updating the VAT specification for a non-itemized expense creates one row from the expense category. + Initialize(); + + // [GIVEN] A non-itemized expense category with 20% VAT and an expense for 120 LCY. + LibraryExpense.CreateExpenseUser(ExpenseUser); + LibraryExpense.CreateExpenseCategory(ExpenseCategory, ExpenseCategory."Reimbursement Type"::"Employee Paid", ExpenseCategory."Expense Detail Required"::" "); + CreateSubcategoryWithVATRate(ExpenseSubCategory, ExpenseCategory.Code, 20, VATPostingSetup); + ExpenseCategory.Validate("VAT Prod. Posting Group", VATPostingSetup."VAT Prod. Posting Group"); + ExpenseCategory.Validate("Default VAT %", VATPostingSetup."VAT %"); + ExpenseCategory.Modify(true); + LibraryExpense.CreateExpense(Expense, ExpenseUser."No.", ExpenseCategory.Code, '', '', true, '', 120); + + // [WHEN] The VAT specification is updated. + Expense.UpdateVATSpecification(Expense."No."); + + // [THEN] One manual VAT specification contains the category defaults and calculated VAT amounts. + ExpenseVATSpecification.SetRange("Expense No.", Expense."No."); + Assert.RecordCount(ExpenseVATSpecification, 1); + ExpenseVATSpecification.FindFirst(); + Assert.AreEqual(ExpenseVATSpecification.Source::Manual, ExpenseVATSpecification.Source, 'The generated VAT specification must be manual.'); + Assert.AreEqual(ExpenseCategory.Code, ExpenseVATSpecification."Expense Category", 'The expense category must be copied.'); + Assert.AreEqual('', ExpenseVATSpecification."Expense Subcategory", 'A non-itemized VAT specification must not have a subcategory.'); + Assert.AreEqual(VATPostingSetup."VAT Bus. Posting Group", ExpenseVATSpecification."VAT Bus. Posting Group", 'The default VAT business posting group must be used.'); + Assert.AreEqual(VATPostingSetup."VAT Prod. Posting Group", ExpenseVATSpecification."VAT Prod. Posting Group", 'The category VAT product posting group must be used.'); + Assert.AreNearlyEqual(20, ExpenseVATSpecification."VAT %", 0.01, 'The category VAT percentage must be used.'); + Assert.AreNearlyEqual(120, ExpenseVATSpecification.Amount, 0.01, 'The expense amount must be used.'); + Assert.AreNearlyEqual(100, ExpenseVATSpecification."VAT Base Amount", 0.01, 'The VAT base amount must be calculated.'); + Assert.AreNearlyEqual(20, ExpenseVATSpecification."VAT Amount", 0.01, 'The VAT amount must be calculated.'); + end; + + [Test] + procedure UpdateVATSpecificationAggregatesItemizationsBySubcategory() + var + Expense: Record Expense; + ExpenseCategory: Record "Expense Category"; + ExpenseItemization: array[2] of Record "Expense Itemization"; + ExpenseSubCategory: Record "Expense Subcategory"; + ExpenseUser: Record "Expense User"; + ExpenseVATSpecification: Record "Expense VAT Specification"; + VATPostingSetup: Record "VAT Posting Setup"; + begin + // [SCENARIO] Updating the VAT specification for an itemized expense aggregates equal category and subcategory rows. + Initialize(); + + // [GIVEN] An itemized expense with two amounts in the same 20% VAT subcategory. + CreateExpenseUserAndCategory(ExpenseUser, ExpenseCategory); + CreateSubcategoryWithVATRate(ExpenseSubCategory, ExpenseCategory.Code, 20, VATPostingSetup); + LibraryExpense.CreateExpense(Expense, ExpenseUser."No.", ExpenseCategory.Code, ExpenseSubCategory.Code, '', true, '', 120); + LibraryExpense.CreateExpenseItemization(ExpenseItemization[1], Expense, ExpenseCategory.Code, ExpenseSubCategory.Code, WorkDate(), 50, 1); + LibraryExpense.CreateExpenseItemization(ExpenseItemization[2], Expense, ExpenseCategory.Code, ExpenseSubCategory.Code, WorkDate(), 70, 1); + + // [WHEN] The VAT specification is updated. + Expense.UpdateVATSpecification(Expense."No."); + + // [THEN] One VAT specification contains the aggregated amount and calculated VAT. + ExpenseVATSpecification.SetRange("Expense No.", Expense."No."); + Assert.RecordCount(ExpenseVATSpecification, 1); + ExpenseVATSpecification.FindFirst(); + Assert.AreEqual(ExpenseCategory.Code, ExpenseVATSpecification."Expense Category", 'The itemization category must be copied.'); + Assert.AreEqual(ExpenseSubCategory.Code, ExpenseVATSpecification."Expense Subcategory", 'The itemization subcategory must be copied.'); + Assert.AreNearlyEqual(120, ExpenseVATSpecification.Amount, 0.01, 'Itemization amounts must be aggregated.'); + Assert.AreNearlyEqual(100, ExpenseVATSpecification."VAT Base Amount", 0.01, 'VAT base must be calculated from the aggregated amount.'); + Assert.AreNearlyEqual(20, ExpenseVATSpecification."VAT Amount", 0.01, 'VAT must be calculated from the aggregated amount.'); + end; + local procedure Initialize() var ExpenseAgentSetup: Record "Expense Agent Setup"; diff --git a/src/Apps/W1/ExpenseAgent/test/src/ExpenseReportTest.Codeunit.al b/src/Apps/W1/ExpenseAgent/test/src/ExpenseReportTest.Codeunit.al index 4d3439a7c03..9fbb4174024 100644 --- a/src/Apps/W1/ExpenseAgent/test/src/ExpenseReportTest.Codeunit.al +++ b/src/Apps/W1/ExpenseAgent/test/src/ExpenseReportTest.Codeunit.al @@ -3251,11 +3251,49 @@ codeunit 148306 "Expense Report Test" FindExpenseReportLine(ExpenseReportLine, Expense); ExpenseSubcategory.Get(Expense."Expense Category", Expense."Expense Subcategory"); Assert.AreEqual( - Format(Amount) + ' - ' + ExpenseSubcategory."Posting Description", + Format(Amount) + ' / ' + ExpenseSubcategory."Posting Description", ExpenseReportLine.Description, StrSubstNo(ValueMustBeEqualErr, ExpenseReportLine.FieldCaption("Description"), Format(Amount), ExpenseReportLine.TableCaption())); end; + [Test] + procedure PostingDescriptionUsesAvailableSubcategoryDescription() + var + ExpenseCategory: Record "Expense Category"; + ExpenseSubcategory: array[2] of Record "Expense Subcategory"; + ExpenseReportLine: Record "Expense Report Line"; + BaseDescription: Text[100]; + begin + // [SCENARIO] The posting description uses the selected subcategory description when it is available. + Initialize(); + + // [GIVEN] A report line whose description already includes its original subcategory description. + LibraryExpense.CreateExpenseCategory(ExpenseCategory, ExpenseCategory."Reimbursement Type"::"Employee Paid", ExpenseCategory."Expense Detail Required"::" "); + LibraryExpense.CreateExpenseSubCategory(ExpenseSubcategory[1], ExpenseCategory.Code, true); + LibraryExpense.CreateExpenseSubCategory(ExpenseSubcategory[2], ExpenseCategory.Code, true); + BaseDescription := ExpenseCategory."Posting Description"; + ExpenseReportLine."Expense Category" := ExpenseCategory.Code; + ExpenseReportLine."Expense Subcategory Code" := ExpenseSubcategory[1].Code; + ExpenseReportLine.Description := CopyStr(BaseDescription + ' / ' + ExpenseSubcategory[1]."Posting Description", 1, MaxStrLen(ExpenseReportLine.Description)); + + // [WHEN] A different subcategory is used for posting. + // [THEN] Its posting description replaces the original subcategory suffix. + Assert.AreEqual( + BaseDescription + ' / ' + ExpenseSubcategory[2]."Posting Description", + ExpenseReportLine.UpdatePostingDescription(ExpenseCategory.Code, ExpenseSubcategory[2].Code), + 'The posting description must use the selected subcategory description.'); + + // [WHEN] The selected subcategory has no posting description. + ExpenseSubcategory[2]."Posting Description" := ''; + ExpenseSubcategory[2].Modify(); + + // [THEN] The base description is retained without a separator. + Assert.AreEqual( + BaseDescription, + ExpenseReportLine.UpdatePostingDescription(ExpenseCategory.Code, ExpenseSubcategory[2].Code), + 'The base posting description must be retained when the subcategory posting description is unavailable.'); + end; + [Test] procedure DescriptionMustBeFlowFromExpenseCategoryInExpenseReport() var From 8a3b45f64c24c72ae98c206f4543b671a5a60ed7 Mon Sep 17 00:00:00 2001 From: Alexander Yakunin Date: Tue, 18 Aug 2026 16:15:25 +0200 Subject: [PATCH 06/29] Add upgrade codeunit for VAT Specs --- src/Apps/W1/ExpenseAgent/app/app.json | 2 +- .../UpgradeExpenseReportVATSpec.Codeunit.al | 118 ++++++++++++++++++ 2 files changed, 119 insertions(+), 1 deletion(-) create mode 100644 src/Apps/W1/ExpenseAgent/app/src/ExpenseReport/Codeunits/UpgradeExpenseReportVATSpec.Codeunit.al diff --git a/src/Apps/W1/ExpenseAgent/app/app.json b/src/Apps/W1/ExpenseAgent/app/app.json index fadf16fcbc8..d81c743ed6d 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": 7105 } ], "features": [ diff --git a/src/Apps/W1/ExpenseAgent/app/src/ExpenseReport/Codeunits/UpgradeExpenseReportVATSpec.Codeunit.al b/src/Apps/W1/ExpenseAgent/app/src/ExpenseReport/Codeunits/UpgradeExpenseReportVATSpec.Codeunit.al new file mode 100644 index 00000000000..9fcea34c1df --- /dev/null +++ b/src/Apps/W1/ExpenseAgent/app/src/ExpenseReport/Codeunits/UpgradeExpenseReportVATSpec.Codeunit.al @@ -0,0 +1,118 @@ +// ------------------------------------------------------------------------------------------------ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. See License.txt in the project root for license information. +// ------------------------------------------------------------------------------------------------ +namespace Microsoft.ExpenseAgent; + +using Microsoft.Finance.Currency; +using System.Upgrade; + +codeunit 7105 "Upgrade Exp. Report VAT Spec" +{ + Access = Internal; + Subtype = Upgrade; + InherentEntitlements = X; + InherentPermissions = X; + Permissions = tabledata Currency = r, + tabledata "Currency Exchange Rate" = r, + tabledata "Expense Report Header" = r, + tabledata "Expense Report Line VAT Spec." = rm, + tabledata "General Ledger Setup" = r, + tabledata "Posted Expense Report Header" = r, + tabledata "Posted Exp. Rep. Line VAT Spec" = rm; + + trigger OnUpgradePerCompany() + begin + BackfillReimbursementAmounts(); + end; + + local procedure BackfillReimbursementAmounts() + var + UpgradeTag: Codeunit "Upgrade Tag"; + begin + if UpgradeTag.HasUpgradeTag(GetBackfillReimbursementAmountsUpgradeTag()) then + exit; + + BackfillExpenseReportLineVATSpecs(); + BackfillPostedExpenseReportLineVATSpecs(); + + UpgradeTag.SetUpgradeTag(GetBackfillReimbursementAmountsUpgradeTag()); + end; + + local procedure BackfillExpenseReportLineVATSpecs() + var + ExpenseReportHeader: Record "Expense Report Header"; + ExpenseReportLineVATSpec: Record "Expense Report Line VAT Spec."; + begin + if not ExpenseReportLineVATSpec.FindSet(true) then + exit; + + repeat + if ExpenseReportHeader.Get(ExpenseReportLineVATSpec."Document No.") then begin + ExpenseReportLineVATSpec.UpdateReimbursementAmounts(ExpenseReportHeader); + ExpenseReportLineVATSpec.Modify(false); + end; + until ExpenseReportLineVATSpec.Next() = 0; + end; + + local procedure BackfillPostedExpenseReportLineVATSpecs() + var + PostedExpenseReportHeader: Record "Posted Expense Report Header"; + PostedExpenseReportLineVATSpec: Record "Posted Exp. Rep. Line VAT Spec"; + begin + if not PostedExpenseReportLineVATSpec.FindSet(true) then + exit; + + repeat + if PostedExpenseReportHeader.Get(PostedExpenseReportLineVATSpec."Expense Report No.") then begin + UpdatePostedReimbursementAmounts(PostedExpenseReportLineVATSpec, PostedExpenseReportHeader); + PostedExpenseReportLineVATSpec.Modify(false); + end; + until PostedExpenseReportLineVATSpec.Next() = 0; + end; + + local procedure UpdatePostedReimbursementAmounts(var PostedExpenseReportLineVATSpec: Record "Posted Exp. Rep. Line VAT Spec"; PostedExpenseReportHeader: Record "Posted Expense Report Header") + var + CurrencyExchangeRate: Record "Currency Exchange Rate"; + ReimbursementCurrency: Record Currency; + begin + ReimbursementCurrency.Initialize(PostedExpenseReportHeader."Reimbursement Currency Code"); + + if PostedExpenseReportHeader."Reimbursement Currency Code" = '' then begin + PostedExpenseReportLineVATSpec."VAT Base Amount (RCY)" := PostedExpenseReportLineVATSpec."VAT Base Amount (LCY)"; + PostedExpenseReportLineVATSpec."VAT Amount (RCY)" := PostedExpenseReportLineVATSpec."VAT Amount (LCY)"; + PostedExpenseReportLineVATSpec."Amount (RCY)" := PostedExpenseReportLineVATSpec."Amount (LCY)"; + end else begin + PostedExpenseReportLineVATSpec."VAT Base Amount (RCY)" := + Round( + CurrencyExchangeRate.ExchangeAmtLCYToFCY( + PostedExpenseReportHeader."Posting Date", PostedExpenseReportHeader."Reimbursement Currency Code", + PostedExpenseReportLineVATSpec."VAT Base Amount (LCY)", PostedExpenseReportHeader."Reimbursement Currency Factor"), + ReimbursementCurrency."Amount Rounding Precision"); + PostedExpenseReportLineVATSpec."VAT Amount (RCY)" := + Round( + CurrencyExchangeRate.ExchangeAmtLCYToFCY( + PostedExpenseReportHeader."Posting Date", PostedExpenseReportHeader."Reimbursement Currency Code", + PostedExpenseReportLineVATSpec."VAT Amount (LCY)", PostedExpenseReportHeader."Reimbursement Currency Factor"), + ReimbursementCurrency."Amount Rounding Precision"); + PostedExpenseReportLineVATSpec."Amount (RCY)" := + PostedExpenseReportLineVATSpec."VAT Base Amount (RCY)" + PostedExpenseReportLineVATSpec."VAT Amount (RCY)"; + end; + + PostedExpenseReportLineVATSpec."Reclaim VAT Amount (RCY)" := + Round( + PostedExpenseReportLineVATSpec."VAT Amount (RCY)" * PostedExpenseReportLineVATSpec."Reclaim %" / 100, + ReimbursementCurrency."Amount Rounding Precision"); + end; + + [EventSubscriber(ObjectType::Codeunit, Codeunit::"Upgrade Tag", OnGetPerCompanyUpgradeTags, '', false, false)] + local procedure RegisterPerCompanyUpgradeTags(var PerCompanyUpgradeTags: List of [Code[250]]) + begin + PerCompanyUpgradeTags.Add(GetBackfillReimbursementAmountsUpgradeTag()); + end; + + local procedure GetBackfillReimbursementAmountsUpgradeTag(): Code[250] + begin + exit('MS-ExpenseAgent-BackfillVATSpecReimbursementAmounts-20260818'); + end; +} From fe1b3e73413990fd4a69a4c144ed6faa7636b5b3 Mon Sep 17 00:00:00 2001 From: Alexander Yakunin Date: Tue, 18 Aug 2026 17:37:06 +0200 Subject: [PATCH 07/29] Resolve review comments --- .../app/src/Expense/Tables/Expense.Table.al | 4 +- .../Codeunits/ExpenseReportPost.Codeunit.al | 24 +- ...al => UpgradeExpReportVATSpec.Codeunit.al} | 1 + .../Tables/ExpenseReportLineVATSpec.Table.al | 4 + .../Tables/PostedExpRepLineVATSpec.Table.al | 4 + .../src/ExpensePostingVATTest.Codeunit.al | 223 +++++++++++++++++- 6 files changed, 238 insertions(+), 22 deletions(-) rename src/Apps/W1/ExpenseAgent/app/src/ExpenseReport/Codeunits/{UpgradeExpenseReportVATSpec.Codeunit.al => UpgradeExpReportVATSpec.Codeunit.al} (99%) diff --git a/src/Apps/W1/ExpenseAgent/app/src/Expense/Tables/Expense.Table.al b/src/Apps/W1/ExpenseAgent/app/src/Expense/Tables/Expense.Table.al index f42e85a3dee..dc099b9a911 100644 --- a/src/Apps/W1/ExpenseAgent/app/src/Expense/Tables/Expense.Table.al +++ b/src/Apps/W1/ExpenseAgent/app/src/Expense/Tables/Expense.Table.al @@ -1358,9 +1358,7 @@ table 6900 Expense end; end; else - if (ExpenseCategory."VAT Prod. Posting Group" <> '') and - (ExpenseCategory."Default VAT %" <> 0) - then begin + if ExpenseCategory."VAT Prod. Posting Group" <> '' then begin ExpenseVATSpec.SetRange(Source); ExpenseVATSpec.DeleteAll(); 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 7210399660e..6032903f200 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 @@ -73,6 +73,7 @@ codeunit 6987 "Expense Report-Post" NoNoreplyAccountErr: Label 'No account is set for sending emails. Set the send mail account for the Expense Agent before sending reimbursement notifications.'; NotApprovedForVATReclaimCategoryErr: Label 'VAT Reclaim Status is not set for Line with Expense Category %1.', Comment = '%1 = Expense Category'; NotApprovedForVATReclaimErr: Label 'VAT Reclaim Status is not set for Line with Expense Category %1 and Expense Subcategory %2.', Comment = '%1 = Expense Category, %2 = Expense Subcategory'; + ShowItLbl: Label 'Show it'; internal procedure RunWithCheck(var ExpenseReportHeader: Record "Expense Report Header") var @@ -232,15 +233,26 @@ codeunit 6987 "Expense Report-Post" ExpenseReportLineVATSpec.SetRange("Document Line No.", ExpenseReportLine."Line No."); if ExpenseReportLineVATSpec.FindSet() then repeat - if ExpenseReportLineVATSpec."Reclaim Status" = ExpenseReportLineVATSpec."Reclaim Status"::"Pending" then begin - if ExpenseReportLineVATSpec."Expense Subcategory" = '' then - Error(NotApprovedForVATReclaimCategoryErr, ExpenseReportLineVATSpec."Expense Category"); - - Error(NotApprovedForVATReclaimErr, ExpenseReportLineVATSpec."Expense Category", ExpenseReportLineVATSpec."Expense Subcategory"); - end; + if ExpenseReportLineVATSpec."Reclaim Status" = ExpenseReportLineVATSpec."Reclaim Status"::"Pending" then + Error(GetPendingVATSpecErrorInfo(ExpenseReportLineVATSpec)); until ExpenseReportLineVATSpec.Next() = 0; end; + local procedure GetPendingVATSpecErrorInfo(ExpenseReportLineVATSpec: Record "Expense Report Line VAT Spec."): ErrorInfo + var + PendingVATSpecErrorInfo: ErrorInfo; + begin + if ExpenseReportLineVATSpec."Expense Subcategory" = '' then + PendingVATSpecErrorInfo.Message := StrSubstNo(NotApprovedForVATReclaimCategoryErr, ExpenseReportLineVATSpec."Expense Category") + else + PendingVATSpecErrorInfo.Message := StrSubstNo(NotApprovedForVATReclaimErr, ExpenseReportLineVATSpec."Expense Category", ExpenseReportLineVATSpec."Expense Subcategory"); + PendingVATSpecErrorInfo.RecordId := ExpenseReportLineVATSpec.RecordId; + PendingVATSpecErrorInfo.FieldNo := ExpenseReportLineVATSpec.FieldNo("Reclaim Status"); + PendingVATSpecErrorInfo.PageNo := Page::"Expense Report Line VAT Spec."; + PendingVATSpecErrorInfo.AddNavigationAction(ShowItLbl); + exit(PendingVATSpecErrorInfo); + end; + local procedure CheckMandatoryFields(var ExpenseReportLine: Record "Expense Report Line") var ExpenseCategory: Record "Expense Category"; diff --git a/src/Apps/W1/ExpenseAgent/app/src/ExpenseReport/Codeunits/UpgradeExpenseReportVATSpec.Codeunit.al b/src/Apps/W1/ExpenseAgent/app/src/ExpenseReport/Codeunits/UpgradeExpReportVATSpec.Codeunit.al similarity index 99% rename from src/Apps/W1/ExpenseAgent/app/src/ExpenseReport/Codeunits/UpgradeExpenseReportVATSpec.Codeunit.al rename to src/Apps/W1/ExpenseAgent/app/src/ExpenseReport/Codeunits/UpgradeExpReportVATSpec.Codeunit.al index 9fcea34c1df..b24ea6e4ca4 100644 --- a/src/Apps/W1/ExpenseAgent/app/src/ExpenseReport/Codeunits/UpgradeExpenseReportVATSpec.Codeunit.al +++ b/src/Apps/W1/ExpenseAgent/app/src/ExpenseReport/Codeunits/UpgradeExpReportVATSpec.Codeunit.al @@ -5,6 +5,7 @@ namespace Microsoft.ExpenseAgent; using Microsoft.Finance.Currency; +using Microsoft.Finance.GeneralLedger.Setup; using System.Upgrade; codeunit 7105 "Upgrade Exp. Report VAT Spec" diff --git a/src/Apps/W1/ExpenseAgent/app/src/ExpenseReport/Tables/ExpenseReportLineVATSpec.Table.al b/src/Apps/W1/ExpenseAgent/app/src/ExpenseReport/Tables/ExpenseReportLineVATSpec.Table.al index b6b75b7791f..b3ca9296297 100644 --- a/src/Apps/W1/ExpenseAgent/app/src/ExpenseReport/Tables/ExpenseReportLineVATSpec.Table.al +++ b/src/Apps/W1/ExpenseAgent/app/src/ExpenseReport/Tables/ExpenseReportLineVATSpec.Table.al @@ -352,6 +352,7 @@ table 6922 "Expense Report Line VAT Spec." AutoFormatType = 1; AutoFormatExpression = GetReimbursementCurrencyCode(); Caption = 'VAT Base Amount (RCY)'; + DataClassification = CustomerContent; Editable = false; ToolTip = 'Specifies the VAT base amount in reimbursement currency for this rate.'; } @@ -360,6 +361,7 @@ table 6922 "Expense Report Line VAT Spec." AutoFormatType = 1; AutoFormatExpression = GetReimbursementCurrencyCode(); Caption = 'VAT Amount (RCY)'; + DataClassification = CustomerContent; Editable = false; ToolTip = 'Specifies the VAT amount in reimbursement currency for this rate.'; } @@ -368,6 +370,7 @@ table 6922 "Expense Report Line VAT Spec." AutoFormatType = 1; AutoFormatExpression = GetReimbursementCurrencyCode(); Caption = 'Amount (RCY)'; + DataClassification = CustomerContent; Editable = false; ToolTip = 'Specifies the total amount in reimbursement currency for this rate.'; } @@ -376,6 +379,7 @@ table 6922 "Expense Report Line VAT Spec." AutoFormatType = 1; AutoFormatExpression = GetReimbursementCurrencyCode(); Caption = 'Reclaim VAT Amount (RCY)'; + DataClassification = CustomerContent; Editable = false; ToolTip = 'Specifies the reclaim VAT amount in reimbursement currency for this rate.'; } diff --git a/src/Apps/W1/ExpenseAgent/app/src/ExpenseReport/Tables/PostedExpRepLineVATSpec.Table.al b/src/Apps/W1/ExpenseAgent/app/src/ExpenseReport/Tables/PostedExpRepLineVATSpec.Table.al index 5533f776d22..55105475af0 100644 --- a/src/Apps/W1/ExpenseAgent/app/src/ExpenseReport/Tables/PostedExpRepLineVATSpec.Table.al +++ b/src/Apps/W1/ExpenseAgent/app/src/ExpenseReport/Tables/PostedExpRepLineVATSpec.Table.al @@ -240,6 +240,7 @@ table 6934 "Posted Exp. Rep. Line VAT Spec" AutoFormatType = 1; AutoFormatExpression = GetReimbursementCurrencyCode(); Caption = 'VAT Base Amount (RCY)'; + DataClassification = CustomerContent; Editable = false; ToolTip = 'Specifies the VAT base amount in reimbursement currency for this rate.'; } @@ -248,6 +249,7 @@ table 6934 "Posted Exp. Rep. Line VAT Spec" AutoFormatType = 1; AutoFormatExpression = GetReimbursementCurrencyCode(); Caption = 'VAT Amount (RCY)'; + DataClassification = CustomerContent; Editable = false; ToolTip = 'Specifies the VAT amount in reimbursement currency for this rate.'; } @@ -256,6 +258,7 @@ table 6934 "Posted Exp. Rep. Line VAT Spec" AutoFormatType = 1; AutoFormatExpression = GetReimbursementCurrencyCode(); Caption = 'Amount (RCY)'; + DataClassification = CustomerContent; Editable = false; ToolTip = 'Specifies the total amount in reimbursement currency for this rate.'; } @@ -264,6 +267,7 @@ table 6934 "Posted Exp. Rep. Line VAT Spec" AutoFormatType = 1; AutoFormatExpression = GetReimbursementCurrencyCode(); Caption = 'Reclaim VAT Amount (RCY)'; + DataClassification = CustomerContent; Editable = false; ToolTip = 'Specifies the reclaim VAT amount in reimbursement currency for this rate.'; } diff --git a/src/Apps/W1/ExpenseAgent/test/src/ExpensePostingVATTest.Codeunit.al b/src/Apps/W1/ExpenseAgent/test/src/ExpensePostingVATTest.Codeunit.al index a804a110e6e..64b8a0eee4a 100644 --- a/src/Apps/W1/ExpenseAgent/test/src/ExpensePostingVATTest.Codeunit.al +++ b/src/Apps/W1/ExpenseAgent/test/src/ExpensePostingVATTest.Codeunit.al @@ -26,8 +26,12 @@ codeunit 148330 "Expense Posting VAT Test" LibraryTestInitialize: Codeunit "Library - Test Initialize"; LibraryUtility: Codeunit "Library - Utility"; IsInitialized: Boolean; + ExpectedExpenseNo: Code[20]; + ExpectedExpenseReportNo: Code[20]; + PostExpenseReportQst: Label 'Do you want to post Expense Report %1?', Comment = '%1 = Expense Report No.'; NotApprovedForVATReclaimCategoryErr: Label 'VAT Reclaim Status is not set for Line with Expense Category %1.', Comment = '%1 = Expense Category'; NotApprovedForVATReclaimErr: Label 'VAT Reclaim Status is not set for Line with Expense Category %1 and Expense Subcategory %2.', Comment = '%1 = Expense Category, %2 = Expense Subcategory'; + ModifyOrDeleteAgentVATSpecErr: Label 'Modifications and delete are not allowed for records created by the Expense Agent API.'; [Test] [HandlerFunctions('ExpensesModalPageHandler,ConfirmHandler')] @@ -329,13 +333,13 @@ codeunit 148330 "Expense Posting VAT Test" // [GIVEN] Expense report is created with expenses added LibraryExpense.CreateExpenseReport(ExpenseReportHeader, ExpenseUser."No.", '', Expense."VAT Bus. Posting Group"); - CreateExpenseReport.AddExpensesToReport(ExpenseReportHeader); + AddExpensesToReport(CreateExpenseReport, ExpenseReportHeader, Expense."No."); UpdateExpenseReportLinesWithVendor(ExpenseReportHeader); UpdateExpenseReportVATSpecLineStatuses(ExpenseReportHeader); ExpenseReportHeader.PerformManualRelease(); // [WHEN] Expense report is posted - ExpenseReportPost.PostExpenseReport(ExpenseReportHeader); + PostExpenseReportWithConfirmation(ExpenseReportPost, ExpenseReportHeader); // [THEN] Posted expense report is created FindPostedExpenseReport(PostedExpenseReportHeader, Expense); @@ -391,12 +395,12 @@ codeunit 148330 "Expense Posting VAT Test" CreateExpenseWithHotelItemizations(Expense, ExpenseUser, ExpenseCategory, ExpenseSubCategory, VATPostingSetup, 100, 50, 0, 0); LibraryExpense.CreateExpenseReport(ExpenseReportHeader, ExpenseUser."No.", '', Expense."VAT Bus. Posting Group"); - CreateExpenseReport.AddExpensesToReport(ExpenseReportHeader); + AddExpensesToReport(CreateExpenseReport, ExpenseReportHeader, Expense."No."); UpdateExpenseReportLinesWithVendorKeepingPending(ExpenseReportHeader); ExpenseReportHeader.PerformManualRelease(); // [WHEN] Posting is attempted - asserterror ExpenseReportPost.PostExpenseReport(ExpenseReportHeader); + asserterror PostExpenseReportWithConfirmation(ExpenseReportPost, ExpenseReportHeader); // [THEN] A reclaim-status error is raised and posting does not create a posted report. Assert.ExpectedError(StrSubstNo(NotApprovedForVATReclaimErr, ExpenseCategory.Code, ExpenseSubCategory[1].Code)); @@ -433,12 +437,12 @@ codeunit 148330 "Expense Posting VAT Test" ReleaseExpenseAndUpdateAccounts(Expense, ExpenseUser); LibraryExpense.CreateExpenseReport(ExpenseReportHeader, ExpenseUser."No.", '', Expense."VAT Bus. Posting Group"); - CreateExpenseReport.AddExpensesToReport(ExpenseReportHeader); + AddExpensesToReport(CreateExpenseReport, ExpenseReportHeader, Expense."No."); UpdateExpenseReportLinesWithVendorKeepingPending(ExpenseReportHeader); ExpenseReportHeader.PerformManualRelease(); // [WHEN] Posting is attempted. - asserterror ExpenseReportPost.PostExpenseReport(ExpenseReportHeader); + asserterror PostExpenseReportWithConfirmation(ExpenseReportPost, ExpenseReportHeader); // [THEN] The error identifies the expense category without requiring a subcategory. Assert.ExpectedError(StrSubstNo(NotApprovedForVATReclaimCategoryErr, ExpenseCategory.Code)); @@ -479,7 +483,7 @@ codeunit 148330 "Expense Posting VAT Test" Currency.Get(CurrencyCode); LibraryExpense.CreateExpenseReport(ExpenseReportHeader, ExpenseUser."No.", CurrencyCode, Expense."VAT Bus. Posting Group"); - CreateExpenseReport.AddExpensesToReport(ExpenseReportHeader); + AddExpensesToReport(CreateExpenseReport, ExpenseReportHeader, Expense."No."); // [WHEN] The VAT rate is changed to 20% on the copied specification. ExpenseReportLineVATSpec.SetRange("Document No.", ExpenseReportHeader."No."); @@ -509,7 +513,7 @@ codeunit 148330 "Expense Posting VAT Test" // [WHEN] The report is approved for reclaim and posted. UpdateExpenseReportLinesWithVendor(ExpenseReportHeader); ExpenseReportHeader.PerformManualRelease(); - ExpenseReportPost.PostExpenseReport(ExpenseReportHeader); + PostExpenseReportWithConfirmation(ExpenseReportPost, ExpenseReportHeader); // [THEN] The VAT specification journal line is posted with RCY as source currency and LCY as G/L amount. FindPostedExpenseReport(PostedExpenseReportHeader, Expense); @@ -530,6 +534,22 @@ codeunit 148330 "Expense Posting VAT Test" Assert.AreNearlyEqual(ExpectedVATAmountRCY, GLEntry."Source Currency VAT Amount", 0.01, 'Posted source currency VAT amount must use the VAT specification reimbursement amount.'); end; + [Test] + [HandlerFunctions('ExpensesModalPageHandler,ConfirmHandler')] + procedure PartialVATReclaimPostsReclaimAmountsAndNonDeductibleVAT() + begin + // [SCENARIO] A partially reclaimable VAT specification posts deductible and non-deductible VAT separately. + VerifyPartialVATReclaimPosting(50, 50, 10, 50, 10, 10, 20); + end; + + [Test] + [HandlerFunctions('ExpensesModalPageHandler,ConfirmHandler')] + procedure AsymmetricPartialVATReclaimPostsDifferentDeductibleAmounts() + begin + // [SCENARIO] An asymmetric partial reclaim keeps deductible and non-deductible VAT amounts distinct. + VerifyPartialVATReclaimPosting(70, 70, 14, 30, 6, 14, 28); + end; + [Test] [HandlerFunctions('ExpensesModalPageHandler,ConfirmHandler')] procedure VATSpecPostingBalancesForeignReimbursementCurrency() @@ -557,7 +577,7 @@ codeunit 148330 "Expense Posting VAT Test" CreateExpenseWithHotelItemizations(Expense, ExpenseUser, ExpenseCategory, ExpenseSubCategory, VATPostingSetup, 100.01, 100.01, 0, 0); CurrencyCode := LibraryERM.CreateCurrencyWithExchangeRate(WorkDate(), 1, 8.56); LibraryExpense.CreateExpenseReport(ExpenseReportHeader, ExpenseUser."No.", CurrencyCode, Expense."VAT Bus. Posting Group"); - CreateExpenseReport.AddExpensesToReport(ExpenseReportHeader); + AddExpensesToReport(CreateExpenseReport, ExpenseReportHeader, Expense."No."); ExpenseReportLineVATSpec.SetRange("Document No.", ExpenseReportHeader."No."); ExpenseReportLineVATSpec.FindFirst(); ExpenseReportLineVATSpec."VAT Base Amount (LCY)" += 0.01; @@ -568,7 +588,7 @@ codeunit 148330 "Expense Posting VAT Test" // [WHEN] The report is approved and posted. UpdateExpenseReportLinesWithVendor(ExpenseReportHeader); ExpenseReportHeader.PerformManualRelease(); - ExpenseReportPost.PostExpenseReport(ExpenseReportHeader); + PostExpenseReportWithConfirmation(ExpenseReportPost, ExpenseReportHeader); // [THEN] The document balances in LCY and reimbursement currency. FindPostedExpenseReport(PostedExpenseReportHeader, Expense); @@ -618,6 +638,86 @@ codeunit 148330 "Expense Posting VAT Test" Assert.AreNearlyEqual(20, ExpenseVATSpecification."VAT Amount", 0.01, 'The VAT amount must be calculated.'); end; + [Test] + procedure UpdateVATSpecificationCreatesSpecForZeroRatedNonItemizedExpense() + var + Expense: Record Expense; + ExpenseCategory: Record "Expense Category"; + ExpenseSubCategory: Record "Expense Subcategory"; + ExpenseUser: Record "Expense User"; + ExpenseVATSpecification: Record "Expense VAT Specification"; + VATPostingSetup: Record "VAT Posting Setup"; + begin + // [SCENARIO] Updating the VAT specification preserves the 0% bucket for a non-itemized expense. + Initialize(); + + // [GIVEN] A zero-rated non-itemized category with a VAT product posting group and an expense for 120 LCY. + LibraryExpense.CreateExpenseUser(ExpenseUser); + LibraryExpense.CreateExpenseCategory(ExpenseCategory, ExpenseCategory."Reimbursement Type"::"Employee Paid", ExpenseCategory."Expense Detail Required"::" "); + CreateSubcategoryWithVATRate(ExpenseSubCategory, ExpenseCategory.Code, 0, VATPostingSetup); + ExpenseCategory.Validate("VAT Prod. Posting Group", VATPostingSetup."VAT Prod. Posting Group"); + ExpenseCategory.Validate("Default VAT %", VATPostingSetup."VAT %"); + ExpenseCategory.Modify(true); + LibraryExpense.CreateExpense(Expense, ExpenseUser."No.", ExpenseCategory.Code, '', '', true, '', 120); + + // [WHEN] The VAT specification is updated. + Expense.UpdateVATSpecification(Expense."No."); + + // [THEN] One zero-rated VAT specification retains the category VAT product posting group. + ExpenseVATSpecification.SetRange("Expense No.", Expense."No."); + Assert.RecordCount(ExpenseVATSpecification, 1); + ExpenseVATSpecification.FindFirst(); + Assert.AreEqual(ExpenseVATSpecification.Source::Manual, ExpenseVATSpecification.Source, 'The generated VAT specification must be manual.'); + Assert.AreEqual(VATPostingSetup."VAT Prod. Posting Group", ExpenseVATSpecification."VAT Prod. Posting Group", 'The zero-rated VAT product posting group must be retained.'); + Assert.AreEqual(0, ExpenseVATSpecification."VAT %", 'The VAT specification must remain zero-rated.'); + Assert.AreNearlyEqual(120, ExpenseVATSpecification."VAT Base Amount", 0.01, 'The full expense amount must be retained as the VAT base.'); + Assert.AreEqual(0, ExpenseVATSpecification."VAT Amount", 'The zero-rated VAT amount must be zero.'); + end; + + [Test] + procedure AgentVATSpecificationCannotBeUpdatedModifiedOrDeleted() + var + Expense: Record Expense; + ExpenseCategory: Record "Expense Category"; + ExpenseSubCategory: Record "Expense Subcategory"; + ExpenseUser: Record "Expense User"; + ExpenseVATSpecification: Record "Expense VAT Specification"; + VATPostingSetup: Record "VAT Posting Setup"; + begin + // [SCENARIO] Agent-authored VAT specifications cannot be regenerated, modified, or deleted. + Initialize(); + + // [GIVEN] An expense with an Agent-authored VAT specification. + LibraryExpense.CreateExpenseUser(ExpenseUser); + LibraryExpense.CreateExpenseCategory(ExpenseCategory, ExpenseCategory."Reimbursement Type"::"Employee Paid", ExpenseCategory."Expense Detail Required"::" "); + CreateSubcategoryWithVATRate(ExpenseSubCategory, ExpenseCategory.Code, 20, VATPostingSetup); + LibraryExpense.CreateExpense(Expense, ExpenseUser."No.", ExpenseCategory.Code, '', '', true, '', 120); + + ExpenseVATSpecification.Init(); + ExpenseVATSpecification."Expense No." := Expense."No."; + ExpenseVATSpecification."Line No." := 1; + ExpenseVATSpecification.Source := ExpenseVATSpecification.Source::Agent; + ExpenseVATSpecification.Amount := 120; + ExpenseVATSpecification.Insert(true); + Commit(); + + // [WHEN] The VAT specification is regenerated, modified, or deleted. + asserterror Expense.UpdateVATSpecification(Expense."No."); + Assert.ExpectedError(ModifyOrDeleteAgentVATSpecErr); + + ExpenseVATSpecification.Amount := 121; + asserterror ExpenseVATSpecification.Modify(true); + Assert.ExpectedError(ModifyOrDeleteAgentVATSpecErr); + + asserterror ExpenseVATSpecification.Delete(true); + Assert.ExpectedError(ModifyOrDeleteAgentVATSpecErr); + + // [THEN] The Agent-authored VAT specification remains unchanged. + ExpenseVATSpecification.Get(Expense."No.", 1); + Assert.AreEqual(ExpenseVATSpecification.Source::Agent, ExpenseVATSpecification.Source, 'The VAT specification source must remain Agent.'); + Assert.AreEqual(120, ExpenseVATSpecification.Amount, 'The Agent-authored VAT specification must remain unchanged.'); + end; + [Test] procedure UpdateVATSpecificationAggregatesItemizationsBySubcategory() var @@ -653,6 +753,81 @@ codeunit 148330 "Expense Posting VAT Test" Assert.AreNearlyEqual(20, ExpenseVATSpecification."VAT Amount", 0.01, 'VAT must be calculated from the aggregated amount.'); end; + local procedure VerifyPartialVATReclaimPosting(ReclaimPct: Decimal; ExpectedDeductibleBase: Decimal; ExpectedDeductibleVAT: Decimal; ExpectedNonDeductibleBase: Decimal; ExpectedNonDeductibleVAT: Decimal; ExpectedReclaimVATLCY: Decimal; ExpectedReclaimVATRCY: Decimal) + var + Expense: Record Expense; + ExpenseUser: Record "Expense User"; + ExpenseCategory: Record "Expense Category"; + ExpenseSubCategory: array[4] of Record "Expense Subcategory"; + ExpenseReportHeader: Record "Expense Report Header"; + ExpenseReportLine: Record "Expense Report Line"; + ExpenseReportLineVATSpec: Record "Expense Report Line VAT Spec."; + PostedExpenseReportHeader: Record "Posted Expense Report Header"; + PostedExpenseReportLineVATSpec: Record "Posted Exp. Rep. Line VAT Spec"; + VATEntry: Record "VAT Entry"; + VATPostingSetup: array[3] of Record "VAT Posting Setup"; + GLEntry: Record "G/L Entry"; + Vendor: Record Vendor; + CreateExpenseReport: Codeunit "Create Expense Report"; + ExpenseReportPost: Codeunit "Expense Report-Post"; + CurrencyCode: Code[10]; + begin + Initialize(); + + // [GIVEN] A 120 LCY expense with 20% VAT and a report reimbursed at 2 RCY per LCY. + CreateExpenseUserAndCategory(ExpenseUser, ExpenseCategory); + CreateSubcategoryWithVATRate(ExpenseSubCategory[1], ExpenseCategory.Code, 20, VATPostingSetup[1]); + VATPostingSetup[1].Validate("Allow Non-Deductible VAT", VATPostingSetup[1]."Allow Non-Deductible VAT"::Allow); + VATPostingSetup[1].Validate("Non-Ded. Purchase VAT Account", LibraryERM.CreateGLAccountNo()); + VATPostingSetup[1].Modify(true); + CreateExpenseWithHotelItemizations(Expense, ExpenseUser, ExpenseCategory, ExpenseSubCategory, VATPostingSetup, 120, 0, 0, 0); + CurrencyCode := LibraryERM.CreateCurrencyWithExchangeRate(WorkDate(), 2, 2); + LibraryExpense.CreateExpenseReport(ExpenseReportHeader, ExpenseUser."No.", CurrencyCode, Expense."VAT Bus. Posting Group"); + AddExpensesToReport(CreateExpenseReport, ExpenseReportHeader, Expense."No."); + + // [GIVEN] The VAT specification is approved with partial reclaim. + LibraryPurchase.CreateVendor(Vendor); + ExpenseReportLine.SetRange("Document No.", ExpenseReportHeader."No."); + ExpenseReportLine.FindFirst(); + ExpenseReportLine.Validate("Vendor No.", Vendor."No."); + ExpenseReportLine.Modify(true); + ExpenseReportLineVATSpec.SetRange("Document No.", ExpenseReportHeader."No."); + ExpenseReportLineVATSpec.SetRange("Document Line No.", ExpenseReportLine."Line No."); + ExpenseReportLineVATSpec.FindFirst(); + ExpenseReportLineVATSpec.Validate("Reclaim %", ReclaimPct); + ExpenseReportLineVATSpec.Validate("Reclaim Status", ExpenseReportLineVATSpec."Reclaim Status"::Approved); + ExpenseReportLineVATSpec.Modify(true); + + // [WHEN] The expense report is posted. + ExpenseReportHeader.PerformManualRelease(); + PostExpenseReportWithConfirmation(ExpenseReportPost, ExpenseReportHeader); + + // [THEN] The posted specification retains the partial reclaim amounts in LCY and reimbursement currency. + FindPostedExpenseReport(PostedExpenseReportHeader, Expense); + PostedExpenseReportLineVATSpec.SetRange("Expense Report No.", PostedExpenseReportHeader."No."); + PostedExpenseReportLineVATSpec.SetFilter("Expense Report Line No.", '<>%1', 0); + PostedExpenseReportLineVATSpec.FindFirst(); + Assert.AreNearlyEqual(ReclaimPct, PostedExpenseReportLineVATSpec."Reclaim %", 0.01, 'The posted VAT specification must retain the partial reclaim percentage.'); + Assert.AreNearlyEqual(ExpectedReclaimVATLCY, PostedExpenseReportLineVATSpec."Reclaim VAT Amount (LCY)", 0.01, 'The posted reclaim VAT amount in LCY must match the partial reclaim percentage.'); + Assert.AreNearlyEqual(ExpectedReclaimVATRCY, PostedExpenseReportLineVATSpec."Reclaim VAT Amount (RCY)", 0.01, 'The posted reclaim VAT amount in reimbursement currency must match the partial reclaim percentage.'); + + // [THEN] The VAT entry contains distinct deductible and non-deductible portions. + VATEntry.SetRange("Document No.", PostedExpenseReportHeader."No."); + VATEntry.SetRange("VAT Bus. Posting Group", VATPostingSetup[1]."VAT Bus. Posting Group"); + VATEntry.SetRange("VAT Prod. Posting Group", VATPostingSetup[1]."VAT Prod. Posting Group"); + VATEntry.CalcSums(Base, Amount, "Non-Deductible VAT Base", "Non-Deductible VAT Amount"); + Assert.AreNearlyEqual(ExpectedDeductibleBase, VATEntry.Base, 0.01, 'The VAT entry must contain the deductible VAT base.'); + Assert.AreNearlyEqual(ExpectedDeductibleVAT, VATEntry.Amount, 0.01, 'The VAT entry must contain the deductible VAT amount.'); + Assert.AreNearlyEqual(ExpectedNonDeductibleBase, VATEntry."Non-Deductible VAT Base", 0.01, 'The VAT entry must contain the non-deductible VAT base.'); + Assert.AreNearlyEqual(ExpectedNonDeductibleVAT, VATEntry."Non-Deductible VAT Amount", 0.01, 'The VAT entry must contain the non-deductible VAT amount.'); + + // [THEN] The non-deductible VAT amount is routed to its configured purchase VAT account. + GLEntry.SetRange("Document No.", PostedExpenseReportHeader."No."); + GLEntry.SetRange("G/L Account No.", VATPostingSetup[1]."Non-Ded. Purchase VAT Account"); + GLEntry.CalcSums(Amount); + Assert.AreNearlyEqual(ExpectedNonDeductibleVAT, GLEntry.Amount, 0.01, 'The non-deductible purchase VAT account must receive the non-reclaimable VAT amount.'); + end; + local procedure Initialize() var ExpenseAgentSetup: Record "Expense Agent Setup"; @@ -660,6 +835,8 @@ codeunit 148330 "Expense Posting VAT Test" VATSetup: Record "VAT Setup"; LibraryERMCountryData: Codeunit "Library - ERM Country Data"; begin + Clear(ExpectedExpenseNo); + Clear(ExpectedExpenseReportNo); LibraryTestInitialize.OnTestInitialize(Codeunit::"Expense Posting VAT Test"); LibraryExpense.CleanUpBeforeTesting(); LibraryExpense.CleanTransactionalData(); @@ -800,10 +977,10 @@ codeunit 148330 "Expense Posting VAT Test" ExpenseReportPost: Codeunit "Expense Report-Post"; begin LibraryExpense.CreateExpenseReport(ExpenseReportHeader, ExpenseUser."No.", '', Expense."VAT Bus. Posting Group"); - CreateExpenseReport.AddExpensesToReport(ExpenseReportHeader); + AddExpensesToReport(CreateExpenseReport, ExpenseReportHeader, Expense."No."); UpdateExpenseReportLinesWithVendor(ExpenseReportHeader); ExpenseReportHeader.PerformManualRelease(); - ExpenseReportPost.PostExpenseReport(ExpenseReportHeader); + PostExpenseReportWithConfirmation(ExpenseReportPost, ExpenseReportHeader); end; local procedure FindPostedExpenseReport(var PostedExpenseReportHeader: Record "Posted Expense Report Header"; Expense: Record Expense) @@ -812,6 +989,20 @@ codeunit 148330 "Expense Posting VAT Test" PostedExpenseReportHeader.FindFirst(); end; + local procedure AddExpensesToReport(var CreateExpenseReport: Codeunit "Create Expense Report"; ExpenseReportHeader: Record "Expense Report Header"; ExpenseNo: Code[20]) + begin + ExpectedExpenseNo := ExpenseNo; + CreateExpenseReport.AddExpensesToReport(ExpenseReportHeader); + Assert.AreEqual('', ExpectedExpenseNo, 'The expected Expenses modal page was not handled.'); + end; + + local procedure PostExpenseReportWithConfirmation(var ExpenseReportPost: Codeunit "Expense Report-Post"; var ExpenseReportHeader: Record "Expense Report Header") + begin + ExpectedExpenseReportNo := ExpenseReportHeader."No."; + ExpenseReportPost.PostExpenseReport(ExpenseReportHeader); + Assert.AreEqual('', ExpectedExpenseReportNo, 'The expected expense report posting confirmation was not handled.'); + end; + local procedure GetRefundableDebitAccount(ExpenseCategoryCode: Code[20]): Code[20] var ExpenseCategory: Record "Expense Category"; @@ -999,7 +1190,7 @@ codeunit 148330 "Expense Posting VAT Test" // [GIVEN] Expense report is created with expenses added LibraryExpense.CreateExpenseReport(ExpenseReportHeader, ExpenseUser."No.", '', Expense."VAT Bus. Posting Group"); - CreateExpenseReport.AddExpensesToReport(ExpenseReportHeader); + AddExpensesToReport(CreateExpenseReport, ExpenseReportHeader, Expense."No."); UpdateExpenseReportLinesWithVendor(ExpenseReportHeader); ExpenseReportHeader.PerformManualRelease(); @@ -1087,12 +1278,18 @@ codeunit 148330 "Expense Posting VAT Test" [ModalPageHandler] procedure ExpensesModalPageHandler(var Expenses: TestPage Expenses) begin + Assert.AreNotEqual('', ExpectedExpenseNo, 'An unexpected Expenses modal page was shown.'); + Expenses."No.".AssertEquals(ExpectedExpenseNo); + Clear(ExpectedExpenseNo); Expenses.OK().Invoke(); end; [ConfirmHandler] procedure ConfirmHandler(Question: Text[1024]; var Reply: Boolean) begin + Assert.AreNotEqual('', ExpectedExpenseReportNo, 'An unexpected confirmation dialog was shown.'); + Assert.AreEqual(StrSubstNo(PostExpenseReportQst, ExpectedExpenseReportNo), Question, 'The expense report posting confirmation is incorrect.'); + Clear(ExpectedExpenseReportNo); Reply := true; end; } From dc420b63d172f6178071675d1bf540eca26ecc34 Mon Sep 17 00:00:00 2001 From: Alexander Yakunin Date: Tue, 18 Aug 2026 17:57:36 +0200 Subject: [PATCH 08/29] Fix more review comments --- .../app/src/APIs/ExpenseVATSpecAPI.Page.al | 25 ++++++++++++++++++- .../Tables/ExpenseVATSpecification.Table.al | 1 + .../Pages/ExpenseReportLineVATSpec.Page.al | 7 +++--- .../Tables/ExpenseReportLine.Table.al | 2 +- .../Tables/ExpenseReportLineVATSpec.Table.al | 4 +-- 5 files changed, 31 insertions(+), 8 deletions(-) diff --git a/src/Apps/W1/ExpenseAgent/app/src/APIs/ExpenseVATSpecAPI.Page.al b/src/Apps/W1/ExpenseAgent/app/src/APIs/ExpenseVATSpecAPI.Page.al index 14afd9224ff..928ca56dde3 100644 --- a/src/Apps/W1/ExpenseAgent/app/src/APIs/ExpenseVATSpecAPI.Page.al +++ b/src/Apps/W1/ExpenseAgent/app/src/APIs/ExpenseVATSpecAPI.Page.al @@ -104,6 +104,29 @@ page 7085 "Expense VAT Spec. API" ExpenseAgentAPIValidation: Codeunit "Expense Agent API Validation"; begin ExpenseAgentAPIValidation.VerifyAgentAccess(); - Source := Source::Agent; + end; + + trigger OnNewRecord(BelowxRec: Boolean) + begin + VerifyExpenseAgentCaller(); + Rec.Source := Rec.Source::Agent; + end; + + trigger OnInsertRecord(BelowxRec: Boolean): Boolean + begin + VerifyExpenseAgentCaller(); + Rec.Source := Rec.Source::Agent; + exit(true); + end; + + var + ExpenseAgentCallerRequiredErr: Label 'Only the Expense Agent application can create agent-authored VAT specifications.'; + + local procedure VerifyExpenseAgentCaller() + var + ExpenseAgentAPIValidation: Codeunit "Expense Agent API Validation"; + begin + if not ExpenseAgentAPIValidation.IsCurrentUserExpenseAgent() then + Error(ExpenseAgentCallerRequiredErr); end; } diff --git a/src/Apps/W1/ExpenseAgent/app/src/Expense/Tables/ExpenseVATSpecification.Table.al b/src/Apps/W1/ExpenseAgent/app/src/Expense/Tables/ExpenseVATSpecification.Table.al index 792f937789c..6157d275f2f 100644 --- a/src/Apps/W1/ExpenseAgent/app/src/Expense/Tables/ExpenseVATSpecification.Table.al +++ b/src/Apps/W1/ExpenseAgent/app/src/Expense/Tables/ExpenseVATSpecification.Table.al @@ -225,6 +225,7 @@ table 6918 "Expense VAT Specification" { Caption = 'Source'; Editable = false; + DataClassification = SystemMetadata; ToolTip = 'Specifies how this VAT specification line was created, for example whether it was entered manually or extracted automatically from a receipt.'; } field(41; Confidence; Decimal) diff --git a/src/Apps/W1/ExpenseAgent/app/src/ExpenseReport/Pages/ExpenseReportLineVATSpec.Page.al b/src/Apps/W1/ExpenseAgent/app/src/ExpenseReport/Pages/ExpenseReportLineVATSpec.Page.al index 5f9ba1a5828..36f38cb312f 100644 --- a/src/Apps/W1/ExpenseAgent/app/src/ExpenseReport/Pages/ExpenseReportLineVATSpec.Page.al +++ b/src/Apps/W1/ExpenseAgent/app/src/ExpenseReport/Pages/ExpenseReportLineVATSpec.Page.al @@ -192,12 +192,11 @@ page 7081 "Expense Report Line VAT Spec." begin ExpenseReportLineVATSpec.SetRange("Document No.", Rec."Document No."); ExpenseReportLineVATSpec.SetRange("Document Line No.", Rec."Document Line No."); + ExpenseReportLineVATSpec.SetRange("Reclaim Status", ExpenseReportLineVATSpec."Reclaim Status"::Pending); if ExpenseReportLineVATSpec.FindSet() then repeat - if ExpenseReportLineVATSpec."Reclaim Status" = ExpenseReportLineVATSpec."Reclaim Status"::Pending then begin - ExpenseReportLineVATSpec.Validate("Reclaim Status", Rec."Reclaim Status"::Approved); - ExpenseReportLineVATSpec.Modify(true); - end; + ExpenseReportLineVATSpec.Validate("Reclaim Status", Rec."Reclaim Status"::Approved); + ExpenseReportLineVATSpec.Modify(true); until ExpenseReportLineVATSpec.Next() = 0; CurrPage.Update(false); end; diff --git a/src/Apps/W1/ExpenseAgent/app/src/ExpenseReport/Tables/ExpenseReportLine.Table.al b/src/Apps/W1/ExpenseAgent/app/src/ExpenseReport/Tables/ExpenseReportLine.Table.al index 09e8f9b3210..0f8829e9ab0 100644 --- a/src/Apps/W1/ExpenseAgent/app/src/ExpenseReport/Tables/ExpenseReportLine.Table.al +++ b/src/Apps/W1/ExpenseAgent/app/src/ExpenseReport/Tables/ExpenseReportLine.Table.al @@ -1393,7 +1393,7 @@ table 6907 "Expense Report Line" exit(UpdatePostingDescription("Expense Category", "Expense Subcategory Code")); end; - procedure UpdatePostingDescription(ExpenseCategoryCode: Code[20]; ExpenseSubcategoryCode: Code[20]): Text[100] + internal procedure UpdatePostingDescription(ExpenseCategoryCode: Code[20]; ExpenseSubcategoryCode: Code[20]): Text[100] var ExpenseSubcategory: Record "Expense Subcategory"; BaseDescription: Text[100]; diff --git a/src/Apps/W1/ExpenseAgent/app/src/ExpenseReport/Tables/ExpenseReportLineVATSpec.Table.al b/src/Apps/W1/ExpenseAgent/app/src/ExpenseReport/Tables/ExpenseReportLineVATSpec.Table.al index b3ca9296297..ef02d2f2d7c 100644 --- a/src/Apps/W1/ExpenseAgent/app/src/ExpenseReport/Tables/ExpenseReportLineVATSpec.Table.al +++ b/src/Apps/W1/ExpenseAgent/app/src/ExpenseReport/Tables/ExpenseReportLineVATSpec.Table.al @@ -486,13 +486,13 @@ table 6922 "Expense Report Line VAT Spec." "Reclaim VAT Amount (RCY)" := Round("VAT Amount (RCY)" * "Reclaim %" / 100, ReimbursementAmountRoundingPrecision); end; - procedure UpdateReimbursementAmounts() + internal procedure UpdateReimbursementAmounts() begin GetExpenseReportHeader(); UpdateReimbursementAmounts(ExpenseReportHeader); end; - procedure UpdateReimbursementAmounts(NewExpenseReportHeader: Record "Expense Report Header") + internal procedure UpdateReimbursementAmounts(NewExpenseReportHeader: Record "Expense Report Header") var ReimbursementCurrency: Record Currency; begin From c033dc9c3e32cb9ccacc89d892c01b106f66f01a Mon Sep 17 00:00:00 2001 From: Alexander Yakunin Date: Tue, 18 Aug 2026 22:35:59 +0200 Subject: [PATCH 09/29] Fix new warnings --- .../Codeunits/CreateExpenseReport.Codeunit.al | 4 +- .../test/src/ExpenseReportTest.Codeunit.al | 44 +++++++++---------- 2 files changed, 24 insertions(+), 24 deletions(-) diff --git a/src/Apps/W1/ExpenseAgent/app/src/ExpenseReport/Codeunits/CreateExpenseReport.Codeunit.al b/src/Apps/W1/ExpenseAgent/app/src/ExpenseReport/Codeunits/CreateExpenseReport.Codeunit.al index 0f45b7c4b46..8ee647bd12b 100644 --- a/src/Apps/W1/ExpenseAgent/app/src/ExpenseReport/Codeunits/CreateExpenseReport.Codeunit.al +++ b/src/Apps/W1/ExpenseAgent/app/src/ExpenseReport/Codeunits/CreateExpenseReport.Codeunit.al @@ -177,7 +177,7 @@ codeunit 6983 "Create Expense Report" ExpenseReportLine.Validate("Expense User No.", Expense."Expense User No."); ExpenseReportLine.Validate("Expense Category", Expense."Expense Category"); ExpenseReportLine.Validate("Expense Subcategory Code", Expense."Expense Subcategory"); - ExpenseReportLine.Validate("Expense Location", Expense."Expense Location"); + ExpenseReportLine.Validate("Expense Location", CopyStr(Expense."Expense Location", 1, 20)); if Expense.Description <> '' then ExpenseReportLine.Validate(Description, Expense.Description); if ExpenseReportLine."Expense Subcategory Code" <> '' then @@ -337,12 +337,12 @@ codeunit 6983 "Create Expense Report" ExpenseReportLineVATSpec.Source := ExpenseVATSpec.Source; ExpenseReportLineVATSpec.Confidence := ExpenseVATSpec.Confidence; ExpenseReportLineVATSpec."Source Spec Line No." := ExpenseVATSpec."Line No."; - ExpenseReportLineVATSpec.UpdateReimbursementAmounts(); if ExpenseSubcategory.Get(ExpenseVATSpec."Expense Category", ExpenseVATSpec."Expense Subcategory") then ExpenseReportLineVATSpec.Validate("Reclaim %", ExpenseSubcategory."Default VAT Reclaim %") else if ExpenseCategory.Get(ExpenseVATSpec."Expense Category") then ExpenseReportLineVATSpec.Validate("Reclaim %", ExpenseCategory."Default VAT Reclaim %"); + ExpenseReportLineVATSpec.UpdateReimbursementAmounts(); ExpenseReportLineVATSpec.Insert(); until ExpenseVATSpec.Next() = 0; diff --git a/src/Apps/W1/ExpenseAgent/test/src/ExpenseReportTest.Codeunit.al b/src/Apps/W1/ExpenseAgent/test/src/ExpenseReportTest.Codeunit.al index 9fbb4174024..acc9a32eae1 100644 --- a/src/Apps/W1/ExpenseAgent/test/src/ExpenseReportTest.Codeunit.al +++ b/src/Apps/W1/ExpenseAgent/test/src/ExpenseReportTest.Codeunit.al @@ -4523,7 +4523,7 @@ codeunit 148306 "Expense Report Test" FindExpenseReportLine(ExpenseReportLine, CopyStr(ExpenseReportPage."No.".Value, 1, 20)); // [GIVEN] Update "Merchant Name" in Expense Report Line. - ExpenseReportLine.Validate("Merchant Name", LibraryRandom.RandText(20)); + ExpenseReportLine.Validate("Merchant Name", CopyStr(LibraryRandom.RandText(20), 1, 100)); ExpenseReportLine.Modify(); // [GIVEN] Enqueue Expense Sub Category Code, Quantity and Amount for Expense Itemization. @@ -4740,7 +4740,7 @@ codeunit 148306 "Expense Report Test" FindExpenseReportLine(ExpenseReportLine, CopyStr(ExpenseReportPage."No.".Value, 1, 20)); // [GIVEN] Update "Merchant Name" in Expense Report Line. - ExpenseReportLine.Validate("Merchant Name", LibraryRandom.RandText(20)); + ExpenseReportLine.Validate("Merchant Name", CopyStr(LibraryRandom.RandText(20), 1, 100)); ExpenseReportLine.Modify(); // [GIVEN] Create Expense Report Line Itemization with Refundable and Non-Refundable Expense Sub Category. @@ -5165,7 +5165,7 @@ codeunit 148306 "Expense Report Test" ReleaseExpenseDocument: Codeunit "Release Expense Document"; Amount: Decimal; CurrencyCode: Code[10]; - NewStartingPoint: Text; + NewStartingPoint: Text[50]; begin // [SCENARIO 617013] Verify Starting Point can be updated in Expense Report Line When Expense Report is created from Expense and Starting Point is blank in Expense. Initialize(); @@ -5192,7 +5192,7 @@ codeunit 148306 "Expense Report Test" FindExpenseReportLine(ExpenseReportLine, Expense); // [GIVEN] Generate New Starting Point. - NewStartingPoint := LibraryRandom.RandText(50); + NewStartingPoint := CopyStr(LibraryRandom.RandText(50), 1, 50); // [WHEN] Update "Starting Point" in Expense Report Line. ExpenseReportLine.Validate("Starting Point", NewStartingPoint); @@ -5215,7 +5215,7 @@ codeunit 148306 "Expense Report Test" ReleaseExpenseDocument: Codeunit "Release Expense Document"; Amount: Decimal; CurrencyCode: Code[10]; - NewEndingPoint: Text; + NewEndingPoint: Text[50]; begin // [SCENARIO 617013] Verify Ending Point can be updated in Expense Report Line When Expense Report is created from Expense and Ending Point is blank in Expense. Initialize(); @@ -5242,7 +5242,7 @@ codeunit 148306 "Expense Report Test" FindExpenseReportLine(ExpenseReportLine, Expense); // [GIVEN] Generate New Ending Point. - NewEndingPoint := LibraryRandom.RandText(50); + NewEndingPoint := CopyStr(LibraryRandom.RandText(50), 1, 50); // [WHEN] Update "Ending Point" in Expense Report Line. ExpenseReportLine.Validate("Ending Point", NewEndingPoint); @@ -6207,8 +6207,8 @@ codeunit 148306 "Expense Report Test" ReleaseExpenseDocument: Codeunit "Release Expense Document"; Amount: Decimal; CurrencyCode: Code[10]; - MerchantName: Text; - ExpenseExtDocNo: Text; + MerchantName: Text[100]; + ExpenseExtDocNo: Text[30]; OriginalMileage: Decimal; begin // [SCENARIO 616955] Verify that Merchant Name and Ext. Doc. No. must flow from Expense to Expense Report. @@ -6223,8 +6223,8 @@ codeunit 148306 "Expense Report Test" // [GIVEN] Generate Random Amount. Amount := LibraryRandom.RandInt(100); OriginalMileage := LibraryRandom.RandDecInRange(50, 500, 2); - MerchantName := LibraryRandom.RandText(20); - ExpenseExtDocNo := LibraryRandom.RandText(20); + MerchantName := CopyStr(LibraryRandom.RandText(20), 1, 100); + ExpenseExtDocNo := CopyStr(LibraryRandom.RandText(20), 1, 30); // [GIVEN] Create Expense with Mileage. CreateExpense(Expense, true, CurrencyCode, Amount); @@ -6375,9 +6375,9 @@ codeunit 148306 "Expense Report Test" CurrencyCode: Code[10]; JobNo: Code[20]; Amount: array[3] of Decimal; - LineDescription: Text; - AdditionalInformation: Text; - LineJustification: Text; + LineDescription: Text[100]; + AdditionalInformation: Text[100]; + LineJustification: Text[100]; i: Integer; begin // [SCENARIO 580731] Verify the data of "Expense Report Details". @@ -6779,10 +6779,10 @@ codeunit 148306 "Expense Report Test" ExpenseUserNo: Code[20]; CurrencyCode: Code[10]; Amount: Decimal; - MerchantName: Text; - LineDescription: Text; - AdditionalInformation: Text; - LineJustification: Text; + MerchantName: Text[100]; + LineDescription: Text[100]; + AdditionalInformation: Text[100]; + LineJustification: Text[100]; IsBillable: Boolean; var JobNo: Code[20]) var @@ -6901,19 +6901,19 @@ codeunit 148306 "Expense Report Test" ExpenseUserNo: Code[20]; CurrencyCode: Code[10]; var Amount: array[3] of Decimal; - LineDescription: Text; - AdditionalInformation: Text; - LineJustification: Text; + LineDescription: Text[100]; + AdditionalInformation: Text[100]; + LineJustification: Text[100]; Refundable: Boolean; var JobNo: Code[20]; NumberOfLines: Integer) var i: Integer; - MerchantName: Text; + MerchantName: Text[100]; begin for i := 1 to NumberOfLines do begin Amount[i] := LibraryRandom.RandIntInRange(100, 200); - MerchantName := LibraryRandom.RandText(20); + MerchantName := CopyStr(LibraryRandom.RandText(20), 1, 100); CreateExpenseReportLine(ExpenseReportLine[i], ExpenseReportHeader, ExpenseUserNo, CurrencyCode, Amount[i], MerchantName, LineDescription, AdditionalInformation, LineJustification, Refundable, JobNo); end; end; From 619d07c496633b45ca2736e7bce5feea1c17d14a Mon Sep 17 00:00:00 2001 From: Alexander Yakunin Date: Wed, 19 Aug 2026 08:14:36 +0200 Subject: [PATCH 10/29] Disable incorrect warnings --- .../app/src/ExpenseReport/Tables/ExpenseReportHeader.Table.al | 4 +++- .../app/src/ExpenseReport/Tables/ExpenseReportLine.Table.al | 2 ++ 2 files changed, 5 insertions(+), 1 deletion(-) 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 a03ddd8929f..ce9958113fe 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 @@ -697,10 +697,12 @@ table 6906 "Expense Report Header" ExpenseReportLineVATSpec: Record "Expense Report Line VAT Spec."; begin ExpenseReportLineVATSpec.SetRange("Document No.", "No."); - if ExpenseReportLineVATSpec.FindSet(true) then + if ExpenseReportLineVATSpec.FindSet() then repeat ExpenseReportLineVATSpec.UpdateReimbursementAmounts(Rec); +#pragma warning disable AA0214 ExpenseReportLineVATSpec.Modify(); +#pragma warning restore AA0214 until ExpenseReportLineVATSpec.Next() = 0; end; diff --git a/src/Apps/W1/ExpenseAgent/app/src/ExpenseReport/Tables/ExpenseReportLine.Table.al b/src/Apps/W1/ExpenseAgent/app/src/ExpenseReport/Tables/ExpenseReportLine.Table.al index 0f8829e9ab0..29378b22fda 100644 --- a/src/Apps/W1/ExpenseAgent/app/src/ExpenseReport/Tables/ExpenseReportLine.Table.al +++ b/src/Apps/W1/ExpenseAgent/app/src/ExpenseReport/Tables/ExpenseReportLine.Table.al @@ -1405,9 +1405,11 @@ table 6907 "Expense Report Line" (ExpenseSubcategory."Posting Description" <> '') then begin PostingDescriptionSuffix := ' / ' + ExpenseSubcategory."Posting Description"; +#pragma warning disable AA0139 if BaseDescription.EndsWith(PostingDescriptionSuffix) then BaseDescription := CopyStr(BaseDescription, 1, StrLen(BaseDescription) - StrLen(PostingDescriptionSuffix)); end; +#pragma warning restore AA0139 if (ExpenseSubcategoryCode = '') or (not ExpenseSubcategory.Get(ExpenseCategoryCode, ExpenseSubcategoryCode)) or From ccab0bb13b21c4b0ba725826367c014b8c794546 Mon Sep 17 00:00:00 2001 From: Alexander Yakunin Date: Wed, 19 Aug 2026 08:20:29 +0200 Subject: [PATCH 11/29] Remove remaining warnings in tests --- .../test/src/ExpenseNoRuleTest.Codeunit.al | 2 +- .../test/src/ExpenseRuleTest.Codeunit.al | 16 ++++++++-------- .../test/src/ExpenseTestII.Codeunit.al | 10 +++++----- 3 files changed, 14 insertions(+), 14 deletions(-) diff --git a/src/Apps/W1/ExpenseAgent/test/src/ExpenseNoRuleTest.Codeunit.al b/src/Apps/W1/ExpenseAgent/test/src/ExpenseNoRuleTest.Codeunit.al index 49c49f1d273..c6fc26b8400 100644 --- a/src/Apps/W1/ExpenseAgent/test/src/ExpenseNoRuleTest.Codeunit.al +++ b/src/Apps/W1/ExpenseAgent/test/src/ExpenseNoRuleTest.Codeunit.al @@ -370,7 +370,7 @@ codeunit 148311 "Expense No Rule Test" FindExpenseReportLine(ExpenseReportLine, CopyStr(ExpenseReportPage."No.".Value(), 1, 20)); // [GIVEN] Update "Merchant Name" in Expense Report Line. - ExpenseReportLine.Validate("Merchant Name", LibraryRandom.RandText(20)); + ExpenseReportLine.Validate("Merchant Name", CopyStr(LibraryRandom.RandText(20), 1, 100)); ExpenseReportLine.Modify(); // [WHEN] Release Expense Report. diff --git a/src/Apps/W1/ExpenseAgent/test/src/ExpenseRuleTest.Codeunit.al b/src/Apps/W1/ExpenseAgent/test/src/ExpenseRuleTest.Codeunit.al index 0bcb7cb1d67..23af114f889 100644 --- a/src/Apps/W1/ExpenseAgent/test/src/ExpenseRuleTest.Codeunit.al +++ b/src/Apps/W1/ExpenseAgent/test/src/ExpenseRuleTest.Codeunit.al @@ -3073,8 +3073,8 @@ codeunit 148301 "Expense Rule Test" ExpenseReportPage.Close(); // [WHEN] Update "Expense Ext. Doc. No.", "Merchant Name" in Expense Report Line. - ExpenseReportLine.Validate("Merchant Name", LibraryRandom.RandText(30)); - ExpenseReportLine.Validate("Expense Ext. Doc. No.", LibraryRandom.RandText(30)); + ExpenseReportLine.Validate("Merchant Name", CopyStr(LibraryRandom.RandText(30), 1, 100)); + ExpenseReportLine.Validate("Expense Ext. Doc. No.", CopyStr(LibraryRandom.RandText(30), 1, 30)); ExpenseReportLine.Modify(); // [THEN] Verify that expense Rule Violation is true before adding Itemization. @@ -3801,13 +3801,13 @@ codeunit 148301 "Expense Rule Test" Commit(); // [WHEN] Update Participant Name. - asserterror ExpenseReportLineParticipant.validate("Participant Name", LibraryRandom.RandText(10)); + asserterror ExpenseReportLineParticipant.validate("Participant Name", CopyStr(LibraryRandom.RandText(10), 1, 100)); // [THEN] Verify that Expense "Participant Employee No." is required when some fields are updating. Assert.ExpectedTestFieldError(ExpenseReportLineParticipant.FieldCaption("Participant Employee No."), ''); // [WHEN] Update Participant Organization. - asserterror ExpenseReportLineParticipant.validate("Participant Organization", LibraryRandom.RandText(10)); + asserterror ExpenseReportLineParticipant.validate("Participant Organization", CopyStr(LibraryRandom.RandText(10), 1, 100)); // [THEN] Verify that Expense "Participant Employee No." is required when some fields are updating. Assert.ExpectedTestFieldError(ExpenseReportLineParticipant.FieldCaption("Participant Employee No."), ''); @@ -3819,13 +3819,13 @@ codeunit 148301 "Expense Rule Test" Assert.ExpectedTestFieldError(ExpenseReportLineParticipant.FieldCaption("Participant Employee No."), ''); // [WHEN] Update Participant Title. - asserterror ExpenseReportLineParticipant.validate("Participant Title", LibraryRandom.RandText(10)); + asserterror ExpenseReportLineParticipant.validate("Participant Title", CopyStr(LibraryRandom.RandText(10), 1, 30)); // [THEN] Verify that Expense "Participant Employee No." is required when some fields are updating. Assert.ExpectedTestFieldError(ExpenseReportLineParticipant.FieldCaption("Participant Employee No."), ''); // [WHEN] Update Participant Email. - asserterror ExpenseReportLineParticipant.validate("Participant Email", LibraryRandom.RandText(10)); + asserterror ExpenseReportLineParticipant.validate("Participant Email", CopyStr(LibraryRandom.RandText(10), 1, 80)); // [THEN] Verify that Expense "Participant Employee No." is required when some fields are updating. Assert.ExpectedTestFieldError(ExpenseReportLineParticipant.FieldCaption("Participant Employee No."), ''); @@ -6991,8 +6991,8 @@ codeunit 148301 "Expense Rule Test" ExpenseReportPage.Close(); // [WHEN] Update "Expense Ext. Doc. No.", "Merchant Name" in Expense Report Line. - ExpenseReportLine.Validate("Merchant Name", LibraryRandom.RandText(30)); - ExpenseReportLine.Validate("Expense Ext. Doc. No.", LibraryRandom.RandText(30)); + ExpenseReportLine.Validate("Merchant Name", CopyStr(LibraryRandom.RandText(30), 1, 100)); + ExpenseReportLine.Validate("Expense Ext. Doc. No.", CopyStr(LibraryRandom.RandText(30), 1, 30)); ExpenseReportLine.Modify(); // [THEN] Verify that expense Rule Violation is true before adding Participant. diff --git a/src/Apps/W1/ExpenseAgent/test/src/ExpenseTestII.Codeunit.al b/src/Apps/W1/ExpenseAgent/test/src/ExpenseTestII.Codeunit.al index 790a75e0a96..722114c2da5 100644 --- a/src/Apps/W1/ExpenseAgent/test/src/ExpenseTestII.Codeunit.al +++ b/src/Apps/W1/ExpenseAgent/test/src/ExpenseTestII.Codeunit.al @@ -1067,7 +1067,7 @@ codeunit 148309 "Expense Test II" // [GIVEN] Create Expense with Expense Category. LibraryExpense.CreateExpense(Expense, ExpenseUser."No.", ExpenseCategory.Code, '', '', true, '', Amount); - Expense.Validate("Merchant Name", LibraryRandom.RandText(10)); + Expense.Validate("Merchant Name", CopyStr(LibraryRandom.RandText(10), 1, 100)); Expense.Modify(); // [WHEN] Update Amount Reduction in Expense to negative value. @@ -1730,14 +1730,14 @@ codeunit 148309 "Expense Test II" // [GIVEN] Create Expense User. LibraryExpense.CreateExpenseUser(ExpenseUser[1]); - ExpenseUser[1].Validate("E-mail", EmailId); + ExpenseUser[1].Validate("E-mail", CopyStr(EmailId, 1, 80)); ExpenseUser[1].Modify(); // [GIVEN] Create another Expense User. LibraryExpense.CreateExpenseUser(ExpenseUser[2]); // [WHEN] Update the same Email ID in another Expense User. - asserterror ExpenseUser[2].Validate("E-mail", EmailId); + asserterror ExpenseUser[2].Validate("E-mail", CopyStr(EmailId, 1, 80)); // [THEN] Verify that the error is thrown for duplicate Email ID in Expense User. Assert.ExpectedError(StrSubstNo(DuplicateEmailErr, ExpenseUser[2].FieldCaption("E-mail"), EmailId, ExpenseUser[2].TableCaption())); @@ -1792,7 +1792,7 @@ codeunit 148309 "Expense Test II" StrSubstNo(ValueMustBeEqualErr, ExpenseUser.FieldCaption("E-mail"), Employee."Company E-Mail", ExpenseUser.TableCaption())); // [WHEN] Update Job Title in Employee. - Employee.Validate("Job Title", LibraryRandom.RandText(20)); + Employee.Validate("Job Title", CopyStr(LibraryRandom.RandText(20), 1, 30)); // [THEN] Verify that the Job Title is updated in Expense User. ExpenseUser.Get(ExpenseUser."No."); @@ -1802,7 +1802,7 @@ codeunit 148309 "Expense Test II" StrSubstNo(ValueMustBeEqualErr, ExpenseUser.FieldCaption("Job Title"), Employee."Job Title", ExpenseUser.TableCaption())); // [WHEN] Update First Name in Employee. - Employee.Validate("First Name", LibraryRandom.RandText(20)); + Employee.Validate("First Name", CopyStr(LibraryRandom.RandText(20), 1, 30)); // [THEN] Verify that the Name is updated in Expense User. ExpenseUser.Get(ExpenseUser."No."); From e4cde25646b3132dbd8020e62163d1b25f8f3c04 Mon Sep 17 00:00:00 2001 From: Alexander Yakunin Date: Wed, 19 Aug 2026 09:09:55 +0200 Subject: [PATCH 12/29] Add obsolete statement --- .../app/src/ExpenseReport/Pages/ExpenseReport.Page.al | 3 +++ 1 file changed, 3 insertions(+) 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 2ff0fa83646..9d0e260e8da 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 @@ -635,6 +635,9 @@ page 6910 "Expense Report" #if not CLEAN29 actionref(VATSpecification_Promoted; VATSpecification) { + ObsoleteReason = 'Replaced by Expense Report Statistics'; + ObsoleteState = Pending; + ObsoleteTag = '29.0'; } #endif actionref("Spend Request_Promoted"; "Spend Request") From 87c3e47a2a07d78c02356b6c72a0192690ea75de Mon Sep 17 00:00:00 2001 From: Alexander Yakunin Date: Wed, 19 Aug 2026 14:34:00 +0200 Subject: [PATCH 13/29] Resolve more review comments --- .../app/src/APIs/ExpenseVATSpecAPI.Page.al | 25 ++- .../ExpenseAgentAPIValidation.Codeunit.al | 23 +++ .../Codeunits/ExpenseReportPost.Codeunit.al | 46 ++++++ .../UpgradeExpReportVATSpec.Codeunit.al | 135 ++++++++++++---- .../Pages/ExpenseReportLineVATSpec.Page.al | 2 +- .../Pages/ExpenseReportVATSpec.Page.al | 1 + .../Pages/PostedExpenseReportVATSpec.Page.al | 1 + .../Tables/ExpenseReportHeader.Table.al | 2 +- .../InstallExpenseAgentSetup.Codeunit.al | 2 + .../src/ExpensePostingVATTest.Codeunit.al | 149 ++++++++++++++++-- 10 files changed, 338 insertions(+), 48 deletions(-) diff --git a/src/Apps/W1/ExpenseAgent/app/src/APIs/ExpenseVATSpecAPI.Page.al b/src/Apps/W1/ExpenseAgent/app/src/APIs/ExpenseVATSpecAPI.Page.al index 928ca56dde3..fb8027e74c1 100644 --- a/src/Apps/W1/ExpenseAgent/app/src/APIs/ExpenseVATSpecAPI.Page.al +++ b/src/Apps/W1/ExpenseAgent/app/src/APIs/ExpenseVATSpecAPI.Page.al @@ -4,6 +4,8 @@ // ------------------------------------------------------------------------------------------------ namespace Microsoft.ExpenseAgent; +using System.Telemetry; + page 7085 "Expense VAT Spec. API" { APIGroup = 'expense'; @@ -18,6 +20,8 @@ page 7085 "Expense VAT Spec. API" ODataKeyFields = SystemId; SourceTable = "Expense VAT Specification"; AboutText = 'Provides access to data from the Expense VAT Specification table'; + ModifyAllowed = false; + DeleteAllowed = false; AutoSplitKey = true; layout @@ -113,10 +117,27 @@ page 7085 "Expense VAT Spec. API" end; trigger OnInsertRecord(BelowxRec: Boolean): Boolean + begin + InsertVATSpecification(Rec); + exit(false); + end; + + internal procedure InsertVATSpecification(var ExpenseVATSpecification: Record "Expense VAT Specification") + var + ExpenseAgentSetup: Record "Expense Agent Setup"; + ExpenseAgentAPIValidation: Codeunit "Expense Agent API Validation"; + FeatureTelemetry: Codeunit "Feature Telemetry"; + TelemetryDimensions: Dictionary of [Text, Text]; begin VerifyExpenseAgentCaller(); - Rec.Source := Rec.Source::Agent; - exit(true); + ExpenseVATSpecification.Source := ExpenseVATSpecification.Source::Agent; + ExpenseAgentAPIValidation.AuthorizeAgentVATSpecificationInsert(); + ExpenseVATSpecification.Insert(true); + + TelemetryDimensions.Add('HasExpenseCategory', Format(ExpenseVATSpecification."Expense Category" <> '')); + TelemetryDimensions.Add('HasExpenseSubcategory', Format(ExpenseVATSpecification."Expense Subcategory" <> '')); + TelemetryDimensions.Add('HasVATProductPostingGroup', Format(ExpenseVATSpecification."VAT Prod. Posting Group" <> '')); + FeatureTelemetry.LogUptake('0000UZ7', ExpenseAgentSetup.GetFeatureName(), Enum::"Feature Uptake Status"::Used, TelemetryDimensions); end; var diff --git a/src/Apps/W1/ExpenseAgent/app/src/Common/ExpenseAgentAPIValidation.Codeunit.al b/src/Apps/W1/ExpenseAgent/app/src/Common/ExpenseAgentAPIValidation.Codeunit.al index ba65a32fc36..c753185572e 100644 --- a/src/Apps/W1/ExpenseAgent/app/src/Common/ExpenseAgentAPIValidation.Codeunit.al +++ b/src/Apps/W1/ExpenseAgent/app/src/Common/ExpenseAgentAPIValidation.Codeunit.al @@ -13,12 +13,35 @@ codeunit 6993 "Expense Agent API Validation" Access = Internal; InherentEntitlements = X; InherentPermissions = X; + SingleInstance = true; var AgentNotEnabledErr: Label 'Expense Agent is not enabled. Please contact your administrator.'; CapabilityNotEnabledErr: Label 'The "%1" capability is not enabled. Please contact your administrator to enable the capability.', Comment = '%1 = a capability name, such as Expense Agent'; + AgentVATSpecInsertAuthorized: Boolean; + AgentVATSpecInsertNotAuthorizedErr: Label 'Agent-authored VAT specifications must be created through an authorized Expense Agent request.'; ExpenseAgentAadAppIdTxt: Label 'ee1eb5fd-719b-44f2-97d0-0efd34bc4148', Locked = true; + procedure AuthorizeAgentVATSpecificationInsert() + begin + if not IsCurrentUserExpenseAgent() then + Error(AgentVATSpecInsertNotAuthorizedErr); + + AgentVATSpecInsertAuthorized := true; + end; + + [EventSubscriber(ObjectType::Table, Database::"Expense VAT Specification", OnBeforeInsertEvent, '', false, false)] + local procedure CheckAgentVATSpecificationInsert(var Rec: Record "Expense VAT Specification"; RunTrigger: Boolean) + begin + if Rec.IsTemporary() or (Rec.Source <> Rec.Source::Agent) then + exit; + + if not AgentVATSpecInsertAuthorized then + Error(AgentVATSpecInsertNotAuthorizedErr); + + AgentVATSpecInsertAuthorized := false; + end; + procedure VerifyAgentAccess() begin VerifyAgentAccess(false); 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 258d726541c..9ec56ad8c6b 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 @@ -17,6 +17,7 @@ using Microsoft.Projects.Project.Journal; using Microsoft.Projects.Project.Posting; using Microsoft.Purchases.Vendor; using Microsoft.Sales.Document; +using System.Telemetry; using System.Utilities; codeunit 6987 "Expense Report-Post" @@ -60,6 +61,7 @@ codeunit 6987 "Expense Report-Post" VATSetup: Record "VAT Setup"; SourceCodeSetup: Record "Source Code Setup"; GenJnlPostLine: Codeunit "Gen. Jnl.-Post Line"; + FeatureTelemetry: Codeunit "Feature Telemetry"; PreviewMode: Boolean; AmountToEmployee: Decimal; AmountToEmployeeLCY: Decimal; @@ -73,12 +75,14 @@ codeunit 6987 "Expense Report-Post" NoNoreplyAccountErr: Label 'No account is set for sending emails. Set the send mail account for the Expense Agent before sending reimbursement notifications.'; NotApprovedForVATReclaimCategoryErr: Label 'VAT Reclaim Status is not set for Line with Expense Category %1.', Comment = '%1 = Expense Category'; NotApprovedForVATReclaimErr: Label 'VAT Reclaim Status is not set for Line with Expense Category %1 and Expense Subcategory %2.', Comment = '%1 = Expense Category, %2 = Expense Subcategory'; + AgentVATSpecificationsPostedLbl: Label 'Agent-authored VAT specifications posted.', Locked = true; ShowItLbl: Label 'Show it'; internal procedure RunWithCheck(var ExpenseReportHeader: Record "Expense Report Header") var GenJnlPostPreview: Codeunit "Gen. Jnl.-Post Preview"; ReleaseExpenseReport: Codeunit "Release Exp. Report Document"; + AgentVATSpecificationCount: Integer; begin SourceCodeSetup.Get(); SourceCodeSetup.TestField(Expense); @@ -93,18 +97,40 @@ codeunit 6987 "Expense Report-Post" ReleaseExpenseReport.Run(ExpenseReportHeader); ExpenseReportHeader.Get(ExpenseReportHeader."No."); // Refresh after release + AgentVATSpecificationCount := GetAgentVATSpecificationCount(ExpenseReportHeader."No."); CheckAndCreatePostedDocument(ExpenseReportHeader); if not PreviewMode then begin if TrySendReimbursementNotification(PostedExpenseReportHeader) then; ExpenseReportHeader.Delete(true); + LogAgentVATSpecificationUsage(AgentVATSpecificationCount); end; if PreviewMode then GenJnlPostPreview.ThrowError(); end; + local procedure GetAgentVATSpecificationCount(ExpenseReportNo: Code[20]): Integer + var + ExpenseReportLineVATSpec: Record "Expense Report Line VAT Spec."; + begin + ExpenseReportLineVATSpec.SetRange("Document No.", ExpenseReportNo); + ExpenseReportLineVATSpec.SetRange(Source, ExpenseReportLineVATSpec.Source::Agent); + exit(ExpenseReportLineVATSpec.Count()); + end; + + local procedure LogAgentVATSpecificationUsage(AgentVATSpecificationCount: Integer) + var + TelemetryDimensions: Dictionary of [Text, Text]; + begin + if AgentVATSpecificationCount = 0 then + exit; + + TelemetryDimensions.Add('VATSpecificationCount', Format(AgentVATSpecificationCount)); + FeatureTelemetry.LogUsage('0000UZ8', ExpenseAgentSetup.GetFeatureName(), AgentVATSpecificationsPostedLbl, TelemetryDimensions); + end; + internal procedure PostExpenseReport(var ExpenseReportHeader: Record "Expense Report Header") var ExpenseReportHeaderCopy: Record "Expense Report Header"; @@ -245,11 +271,29 @@ codeunit 6987 "Expense Report-Post" ExpenseReportLineVATSpec.SetRange("Document Line No.", ExpenseReportLine."Line No."); if ExpenseReportLineVATSpec.FindSet() then repeat + ValidateVATSpecMasterRecords(ExpenseReportLineVATSpec); if ExpenseReportLineVATSpec."Reclaim Status" = ExpenseReportLineVATSpec."Reclaim Status"::"Pending" then Error(GetPendingVATSpecErrorInfo(ExpenseReportLineVATSpec)); until ExpenseReportLineVATSpec.Next() = 0; end; + local procedure ValidateVATSpecMasterRecords(ExpenseReportLineVATSpec: Record "Expense Report Line VAT Spec.") + var + ExpenseCategory: Record "Expense Category"; + ExpenseSubcategory: Record "Expense Subcategory"; + begin + if ExpenseReportLineVATSpec."Expense Category" = '' then + exit; + + ExpenseCategory.Get(ExpenseReportLineVATSpec."Expense Category"); + ExpenseCategory.TestField(Inactive, false); + + if ExpenseReportLineVATSpec."Expense Subcategory" <> '' then begin + ExpenseSubcategory.Get(ExpenseReportLineVATSpec."Expense Category", ExpenseReportLineVATSpec."Expense Subcategory"); + ExpenseSubcategory.TestField(Inactive, false); + end; + end; + local procedure GetPendingVATSpecErrorInfo(ExpenseReportLineVATSpec: Record "Expense Report Line VAT Spec."): ErrorInfo var PendingVATSpecErrorInfo: ErrorInfo; @@ -258,6 +302,8 @@ codeunit 6987 "Expense Report-Post" PendingVATSpecErrorInfo.Message := StrSubstNo(NotApprovedForVATReclaimCategoryErr, ExpenseReportLineVATSpec."Expense Category") else PendingVATSpecErrorInfo.Message := StrSubstNo(NotApprovedForVATReclaimErr, ExpenseReportLineVATSpec."Expense Category", ExpenseReportLineVATSpec."Expense Subcategory"); + PendingVATSpecErrorInfo.DataClassification := DataClassification::SystemMetadata; + PendingVATSpecErrorInfo.ErrorType := ErrorType::Client; PendingVATSpecErrorInfo.RecordId := ExpenseReportLineVATSpec.RecordId; PendingVATSpecErrorInfo.FieldNo := ExpenseReportLineVATSpec.FieldNo("Reclaim Status"); PendingVATSpecErrorInfo.PageNo := Page::"Expense Report Line VAT Spec."; diff --git a/src/Apps/W1/ExpenseAgent/app/src/ExpenseReport/Codeunits/UpgradeExpReportVATSpec.Codeunit.al b/src/Apps/W1/ExpenseAgent/app/src/ExpenseReport/Codeunits/UpgradeExpReportVATSpec.Codeunit.al index b24ea6e4ca4..eb47771f00e 100644 --- a/src/Apps/W1/ExpenseAgent/app/src/ExpenseReport/Codeunits/UpgradeExpReportVATSpec.Codeunit.al +++ b/src/Apps/W1/ExpenseAgent/app/src/ExpenseReport/Codeunits/UpgradeExpReportVATSpec.Codeunit.al @@ -34,44 +34,127 @@ codeunit 7105 "Upgrade Exp. Report VAT Spec" if UpgradeTag.HasUpgradeTag(GetBackfillReimbursementAmountsUpgradeTag()) then exit; + CopyReimbursementAmountsFromLCY(); BackfillExpenseReportLineVATSpecs(); BackfillPostedExpenseReportLineVATSpecs(); - UpgradeTag.SetUpgradeTag(GetBackfillReimbursementAmountsUpgradeTag()); + SetBackfillReimbursementAmountsUpgradeTag(); + end; + + internal procedure SetBackfillReimbursementAmountsUpgradeTag() + var + UpgradeTag: Codeunit "Upgrade Tag"; + begin + if not UpgradeTag.HasUpgradeTag(GetBackfillReimbursementAmountsUpgradeTag()) then + UpgradeTag.SetUpgradeTag(GetBackfillReimbursementAmountsUpgradeTag()); + end; + + local procedure CopyReimbursementAmountsFromLCY() + var + ExpenseReportLineVATSpec: Record "Expense Report Line VAT Spec."; + PostedExpenseReportLineVATSpec: Record "Posted Exp. Rep. Line VAT Spec"; + ExpenseReportLineVATSpecDataTransfer: DataTransfer; + PostedExpenseReportLineVATSpecDataTransfer: DataTransfer; + begin + ExpenseReportLineVATSpecDataTransfer.SetTables(Database::"Expense Report Line VAT Spec.", Database::"Expense Report Line VAT Spec."); + ExpenseReportLineVATSpecDataTransfer.AddFieldValue(ExpenseReportLineVATSpec.FieldNo("VAT Base Amount (LCY)"), ExpenseReportLineVATSpec.FieldNo("VAT Base Amount (RCY)")); + ExpenseReportLineVATSpecDataTransfer.AddFieldValue(ExpenseReportLineVATSpec.FieldNo("VAT Amount (LCY)"), ExpenseReportLineVATSpec.FieldNo("VAT Amount (RCY)")); + ExpenseReportLineVATSpecDataTransfer.AddFieldValue(ExpenseReportLineVATSpec.FieldNo("Amount (LCY)"), ExpenseReportLineVATSpec.FieldNo("Amount (RCY)")); + ExpenseReportLineVATSpecDataTransfer.CopyFields(); + + PostedExpenseReportLineVATSpecDataTransfer.SetTables(Database::"Posted Exp. Rep. Line VAT Spec", Database::"Posted Exp. Rep. Line VAT Spec"); + PostedExpenseReportLineVATSpecDataTransfer.AddFieldValue(PostedExpenseReportLineVATSpec.FieldNo("VAT Base Amount (LCY)"), PostedExpenseReportLineVATSpec.FieldNo("VAT Base Amount (RCY)")); + PostedExpenseReportLineVATSpecDataTransfer.AddFieldValue(PostedExpenseReportLineVATSpec.FieldNo("VAT Amount (LCY)"), PostedExpenseReportLineVATSpec.FieldNo("VAT Amount (RCY)")); + PostedExpenseReportLineVATSpecDataTransfer.AddFieldValue(PostedExpenseReportLineVATSpec.FieldNo("Amount (LCY)"), PostedExpenseReportLineVATSpec.FieldNo("Amount (RCY)")); + PostedExpenseReportLineVATSpecDataTransfer.CopyFields(); end; local procedure BackfillExpenseReportLineVATSpecs() var ExpenseReportHeader: Record "Expense Report Header"; ExpenseReportLineVATSpec: Record "Expense Report Line VAT Spec."; + CachedDocumentNo: Code[20]; + HasCachedDocumentNo: Boolean; + HeaderFound: Boolean; begin - if not ExpenseReportLineVATSpec.FindSet(true) then + ExpenseReportLineVATSpec.SetCurrentKey("Document No.", "Document Line No.", "Line No."); + if not ExpenseReportLineVATSpec.FindSet() then exit; + CachedDocumentNo := ''; repeat - if ExpenseReportHeader.Get(ExpenseReportLineVATSpec."Document No.") then begin - ExpenseReportLineVATSpec.UpdateReimbursementAmounts(ExpenseReportHeader); - ExpenseReportLineVATSpec.Modify(false); + if (not HasCachedDocumentNo) or (CachedDocumentNo <> ExpenseReportLineVATSpec."Document No.") then begin + CachedDocumentNo := ExpenseReportLineVATSpec."Document No."; + HasCachedDocumentNo := true; + HeaderFound := ExpenseReportHeader.Get(CachedDocumentNo); end; + + if HeaderFound then + if ExpenseReportHeader."Reimbursement Currency Code" <> '' then begin + ExpenseReportLineVATSpec.UpdateReimbursementAmounts(ExpenseReportHeader); + ExpenseReportLineVATSpec.Modify(false); + end else + if ExpenseReportLineVATSpec."Reclaim %" <> 0 then begin + UpdateExpenseReportLineReclaimAmount(ExpenseReportLineVATSpec); + ExpenseReportLineVATSpec.Modify(false); + end; until ExpenseReportLineVATSpec.Next() = 0; end; + local procedure UpdateExpenseReportLineReclaimAmount(var ExpenseReportLineVATSpec: Record "Expense Report Line VAT Spec.") + var + ReimbursementCurrency: Record Currency; + begin + ReimbursementCurrency.Initialize(''); + ExpenseReportLineVATSpec."Reclaim VAT Amount (RCY)" := + Round( + ExpenseReportLineVATSpec."VAT Amount (RCY)" * ExpenseReportLineVATSpec."Reclaim %" / 100, + ReimbursementCurrency."Amount Rounding Precision"); + end; + local procedure BackfillPostedExpenseReportLineVATSpecs() var PostedExpenseReportHeader: Record "Posted Expense Report Header"; PostedExpenseReportLineVATSpec: Record "Posted Exp. Rep. Line VAT Spec"; + CachedExpenseReportNo: Code[20]; + HasCachedExpenseReportNo: Boolean; + HeaderFound: Boolean; begin - if not PostedExpenseReportLineVATSpec.FindSet(true) then + PostedExpenseReportLineVATSpec.SetCurrentKey("Expense Report No.", "Expense Report Line No.", "Line No."); + if not PostedExpenseReportLineVATSpec.FindSet() then exit; + CachedExpenseReportNo := ''; repeat - if PostedExpenseReportHeader.Get(PostedExpenseReportLineVATSpec."Expense Report No.") then begin - UpdatePostedReimbursementAmounts(PostedExpenseReportLineVATSpec, PostedExpenseReportHeader); - PostedExpenseReportLineVATSpec.Modify(false); + if (not HasCachedExpenseReportNo) or (CachedExpenseReportNo <> PostedExpenseReportLineVATSpec."Expense Report No.") then begin + CachedExpenseReportNo := PostedExpenseReportLineVATSpec."Expense Report No."; + HasCachedExpenseReportNo := true; + HeaderFound := PostedExpenseReportHeader.Get(CachedExpenseReportNo); end; + + if HeaderFound then + if PostedExpenseReportHeader."Reimbursement Currency Code" <> '' then begin + UpdatePostedReimbursementAmounts(PostedExpenseReportLineVATSpec, PostedExpenseReportHeader); + PostedExpenseReportLineVATSpec.Modify(false); + end else + if PostedExpenseReportLineVATSpec."Reclaim %" <> 0 then begin + UpdatePostedReclaimAmount(PostedExpenseReportLineVATSpec); + PostedExpenseReportLineVATSpec.Modify(false); + end; until PostedExpenseReportLineVATSpec.Next() = 0; end; + local procedure UpdatePostedReclaimAmount(var PostedExpenseReportLineVATSpec: Record "Posted Exp. Rep. Line VAT Spec") + var + ReimbursementCurrency: Record Currency; + begin + ReimbursementCurrency.Initialize(''); + PostedExpenseReportLineVATSpec."Reclaim VAT Amount (RCY)" := + Round( + PostedExpenseReportLineVATSpec."VAT Amount (RCY)" * PostedExpenseReportLineVATSpec."Reclaim %" / 100, + ReimbursementCurrency."Amount Rounding Precision"); + end; + local procedure UpdatePostedReimbursementAmounts(var PostedExpenseReportLineVATSpec: Record "Posted Exp. Rep. Line VAT Spec"; PostedExpenseReportHeader: Record "Posted Expense Report Header") var CurrencyExchangeRate: Record "Currency Exchange Rate"; @@ -79,26 +162,20 @@ codeunit 7105 "Upgrade Exp. Report VAT Spec" begin ReimbursementCurrency.Initialize(PostedExpenseReportHeader."Reimbursement Currency Code"); - if PostedExpenseReportHeader."Reimbursement Currency Code" = '' then begin - PostedExpenseReportLineVATSpec."VAT Base Amount (RCY)" := PostedExpenseReportLineVATSpec."VAT Base Amount (LCY)"; - PostedExpenseReportLineVATSpec."VAT Amount (RCY)" := PostedExpenseReportLineVATSpec."VAT Amount (LCY)"; - PostedExpenseReportLineVATSpec."Amount (RCY)" := PostedExpenseReportLineVATSpec."Amount (LCY)"; - end else begin - PostedExpenseReportLineVATSpec."VAT Base Amount (RCY)" := - Round( - CurrencyExchangeRate.ExchangeAmtLCYToFCY( - PostedExpenseReportHeader."Posting Date", PostedExpenseReportHeader."Reimbursement Currency Code", - PostedExpenseReportLineVATSpec."VAT Base Amount (LCY)", PostedExpenseReportHeader."Reimbursement Currency Factor"), - ReimbursementCurrency."Amount Rounding Precision"); - PostedExpenseReportLineVATSpec."VAT Amount (RCY)" := - Round( - CurrencyExchangeRate.ExchangeAmtLCYToFCY( - PostedExpenseReportHeader."Posting Date", PostedExpenseReportHeader."Reimbursement Currency Code", - PostedExpenseReportLineVATSpec."VAT Amount (LCY)", PostedExpenseReportHeader."Reimbursement Currency Factor"), - ReimbursementCurrency."Amount Rounding Precision"); - PostedExpenseReportLineVATSpec."Amount (RCY)" := - PostedExpenseReportLineVATSpec."VAT Base Amount (RCY)" + PostedExpenseReportLineVATSpec."VAT Amount (RCY)"; - end; + PostedExpenseReportLineVATSpec."VAT Base Amount (RCY)" := + Round( + CurrencyExchangeRate.ExchangeAmtLCYToFCY( + PostedExpenseReportHeader."Posting Date", PostedExpenseReportHeader."Reimbursement Currency Code", + PostedExpenseReportLineVATSpec."VAT Base Amount (LCY)", PostedExpenseReportHeader."Reimbursement Currency Factor"), + ReimbursementCurrency."Amount Rounding Precision"); + PostedExpenseReportLineVATSpec."VAT Amount (RCY)" := + Round( + CurrencyExchangeRate.ExchangeAmtLCYToFCY( + PostedExpenseReportHeader."Posting Date", PostedExpenseReportHeader."Reimbursement Currency Code", + PostedExpenseReportLineVATSpec."VAT Amount (LCY)", PostedExpenseReportHeader."Reimbursement Currency Factor"), + ReimbursementCurrency."Amount Rounding Precision"); + PostedExpenseReportLineVATSpec."Amount (RCY)" := + PostedExpenseReportLineVATSpec."VAT Base Amount (RCY)" + PostedExpenseReportLineVATSpec."VAT Amount (RCY)"; PostedExpenseReportLineVATSpec."Reclaim VAT Amount (RCY)" := Round( diff --git a/src/Apps/W1/ExpenseAgent/app/src/ExpenseReport/Pages/ExpenseReportLineVATSpec.Page.al b/src/Apps/W1/ExpenseAgent/app/src/ExpenseReport/Pages/ExpenseReportLineVATSpec.Page.al index 36f38cb312f..b7d490658d1 100644 --- a/src/Apps/W1/ExpenseAgent/app/src/ExpenseReport/Pages/ExpenseReportLineVATSpec.Page.al +++ b/src/Apps/W1/ExpenseAgent/app/src/ExpenseReport/Pages/ExpenseReportLineVATSpec.Page.al @@ -193,7 +193,7 @@ page 7081 "Expense Report Line VAT Spec." ExpenseReportLineVATSpec.SetRange("Document No.", Rec."Document No."); ExpenseReportLineVATSpec.SetRange("Document Line No.", Rec."Document Line No."); ExpenseReportLineVATSpec.SetRange("Reclaim Status", ExpenseReportLineVATSpec."Reclaim Status"::Pending); - if ExpenseReportLineVATSpec.FindSet() then + if ExpenseReportLineVATSpec.FindSet(true) then repeat ExpenseReportLineVATSpec.Validate("Reclaim Status", Rec."Reclaim Status"::Approved); ExpenseReportLineVATSpec.Modify(true); diff --git a/src/Apps/W1/ExpenseAgent/app/src/ExpenseReport/Pages/ExpenseReportVATSpec.Page.al b/src/Apps/W1/ExpenseAgent/app/src/ExpenseReport/Pages/ExpenseReportVATSpec.Page.al index 3b8cd3b6820..2515245e9fa 100644 --- a/src/Apps/W1/ExpenseAgent/app/src/ExpenseReport/Pages/ExpenseReportVATSpec.Page.al +++ b/src/Apps/W1/ExpenseAgent/app/src/ExpenseReport/Pages/ExpenseReportVATSpec.Page.al @@ -100,6 +100,7 @@ page 7096 "Expense Report VAT Spec." if Rec.GetFilter("Document No.") = '' then exit; + ExpenseReportHeader.SetLoadFields("Reimbursement Currency Code"); if ExpenseReportHeader.Get(Rec.GetRangeMin("Document No.")) then ShowRCYFields := ExpenseReportHeader."Reimbursement Currency Code" <> ''; end; diff --git a/src/Apps/W1/ExpenseAgent/app/src/ExpenseReport/Pages/PostedExpenseReportVATSpec.Page.al b/src/Apps/W1/ExpenseAgent/app/src/ExpenseReport/Pages/PostedExpenseReportVATSpec.Page.al index 328c3bf2fc9..cf233a717f7 100644 --- a/src/Apps/W1/ExpenseAgent/app/src/ExpenseReport/Pages/PostedExpenseReportVATSpec.Page.al +++ b/src/Apps/W1/ExpenseAgent/app/src/ExpenseReport/Pages/PostedExpenseReportVATSpec.Page.al @@ -108,6 +108,7 @@ page 7093 "Posted Expense Report VAT Spec" if Rec.GetFilter("Expense Report No.") = '' then exit; + PostedExpenseReportHeader.SetLoadFields("Reimbursement Currency Code"); if PostedExpenseReportHeader.Get(Rec.GetRangeMin("Expense Report No.")) then ShowRCYFields := PostedExpenseReportHeader."Reimbursement Currency Code" <> ''; end; 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 ce9958113fe..3c94f3f5809 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 @@ -697,7 +697,7 @@ table 6906 "Expense Report Header" ExpenseReportLineVATSpec: Record "Expense Report Line VAT Spec."; begin ExpenseReportLineVATSpec.SetRange("Document No.", "No."); - if ExpenseReportLineVATSpec.FindSet() then + if ExpenseReportLineVATSpec.FindSet(true) then repeat ExpenseReportLineVATSpec.UpdateReimbursementAmounts(Rec); #pragma warning disable AA0214 diff --git a/src/Apps/W1/ExpenseAgent/app/src/Setup/Codeunits/InstallExpenseAgentSetup.Codeunit.al b/src/Apps/W1/ExpenseAgent/app/src/Setup/Codeunits/InstallExpenseAgentSetup.Codeunit.al index 18143576fa2..7ab98a9c072 100644 --- a/src/Apps/W1/ExpenseAgent/app/src/Setup/Codeunits/InstallExpenseAgentSetup.Codeunit.al +++ b/src/Apps/W1/ExpenseAgent/app/src/Setup/Codeunits/InstallExpenseAgentSetup.Codeunit.al @@ -19,8 +19,10 @@ codeunit 6992 "Install Expense Agent Setup" trigger OnInstallAppPerCompany() var ExpenseAgentSetup: Record "Expense Agent Setup"; + UpgradeExpReportVATSpec: Codeunit "Upgrade Exp. Report VAT Spec"; begin ExpenseAgentSetup.InitRecord(); + UpgradeExpReportVATSpec.SetBackfillReimbursementAmountsUpgradeTag(); end; trigger OnInstallAppPerDatabase() diff --git a/src/Apps/W1/ExpenseAgent/test/src/ExpensePostingVATTest.Codeunit.al b/src/Apps/W1/ExpenseAgent/test/src/ExpensePostingVATTest.Codeunit.al index 64b8a0eee4a..7823c7e871d 100644 --- a/src/Apps/W1/ExpenseAgent/test/src/ExpensePostingVATTest.Codeunit.al +++ b/src/Apps/W1/ExpenseAgent/test/src/ExpensePostingVATTest.Codeunit.al @@ -11,6 +11,7 @@ using Microsoft.Finance.VAT.Ledger; using Microsoft.Finance.VAT.Setup; using Microsoft.HumanResources.Employee; using Microsoft.Purchases.Vendor; +using System.TestLibraries.Utilities; codeunit 148330 "Expense Posting VAT Test" { @@ -25,12 +26,12 @@ codeunit 148330 "Expense Posting VAT Test" LibraryPurchase: Codeunit "Library - Purchase"; LibraryTestInitialize: Codeunit "Library - Test Initialize"; LibraryUtility: Codeunit "Library - Utility"; + LibraryVariableStorage: Codeunit "Library - Variable Storage"; IsInitialized: Boolean; - ExpectedExpenseNo: Code[20]; - ExpectedExpenseReportNo: Code[20]; PostExpenseReportQst: Label 'Do you want to post Expense Report %1?', Comment = '%1 = Expense Report No.'; NotApprovedForVATReclaimCategoryErr: Label 'VAT Reclaim Status is not set for Line with Expense Category %1.', Comment = '%1 = Expense Category'; NotApprovedForVATReclaimErr: Label 'VAT Reclaim Status is not set for Line with Expense Category %1 and Expense Subcategory %2.', Comment = '%1 = Expense Category, %2 = Expense Subcategory'; + AgentVATSpecInsertNotAuthorizedErr: Label 'Agent-authored VAT specifications must be created through an authorized Expense Agent request.'; ModifyOrDeleteAgentVATSpecErr: Label 'Modifications and delete are not allowed for records created by the Expense Agent API.'; [Test] @@ -404,6 +405,7 @@ codeunit 148330 "Expense Posting VAT Test" // [THEN] A reclaim-status error is raised and posting does not create a posted report. Assert.ExpectedError(StrSubstNo(NotApprovedForVATReclaimErr, ExpenseCategory.Code, ExpenseSubCategory[1].Code)); + LibraryVariableStorage.AssertEmpty(); PostedExpenseReportHeader.SetRange("Expense User No.", ExpenseUser."No."); Assert.RecordCount(PostedExpenseReportHeader, 0); end; @@ -446,10 +448,104 @@ codeunit 148330 "Expense Posting VAT Test" // [THEN] The error identifies the expense category without requiring a subcategory. Assert.ExpectedError(StrSubstNo(NotApprovedForVATReclaimCategoryErr, ExpenseCategory.Code)); + LibraryVariableStorage.AssertEmpty(); PostedExpenseReportHeader.SetRange("Expense User No.", ExpenseUser."No."); Assert.RecordCount(PostedExpenseReportHeader, 0); end; + [Test] + [HandlerFunctions('ExpensesModalPageHandler,ConfirmHandler')] + procedure InactiveVATSpecExpenseCategoryBlocksPosting() + var + Expense: Record Expense; + ExpenseCategory: Record "Expense Category"; + VATSpecExpenseCategory: Record "Expense Category"; + ExpenseItemization: Record "Expense Itemization"; + ExpenseReportHeader: Record "Expense Report Header"; + ExpenseReportLineVATSpec: Record "Expense Report Line VAT Spec."; + ExpenseSubcategory: Record "Expense Subcategory"; + ExpenseUser: Record "Expense User"; + VATPostingSetup: Record "VAT Posting Setup"; + CreateExpenseReport: Codeunit "Create Expense Report"; + ExpenseReportPost: Codeunit "Expense Report-Post"; + begin + // [SCENARIO] Posting is blocked when a VAT specification references an inactive expense category. + Initialize(); + + // [GIVEN] An expense report whose parent line references active expense masters. + CreateExpenseUserAndCategory(ExpenseUser, ExpenseCategory); + CreateSubcategoryWithVATRate(ExpenseSubcategory, ExpenseCategory.Code, 20, VATPostingSetup); + CreateExpenseForHotel(Expense, ExpenseUser, ExpenseCategory, VATPostingSetup, 120); + LibraryExpense.CreateExpenseItemization(ExpenseItemization, Expense, ExpenseCategory.Code, ExpenseSubcategory.Code, WorkDate(), 120, 1); + CreateExpenseVATSpecification(Expense, ExpenseCategory.Code, ExpenseSubcategory.Code, 120); + ReleaseExpenseAndUpdateAccounts(Expense, ExpenseUser); + + LibraryExpense.CreateExpenseReport(ExpenseReportHeader, ExpenseUser."No.", '', Expense."VAT Bus. Posting Group"); + AddExpensesToReport(CreateExpenseReport, ExpenseReportHeader, Expense."No."); + UpdateExpenseReportLinesWithVendor(ExpenseReportHeader); + + // [GIVEN] The VAT specification references a different category that becomes inactive after release. + LibraryExpense.CreateExpenseCategory(VATSpecExpenseCategory, VATSpecExpenseCategory."Reimbursement Type"::"Employee Paid", VATSpecExpenseCategory."Expense Detail Required"::" "); + ExpenseReportLineVATSpec.SetRange("Document No.", ExpenseReportHeader."No."); + ExpenseReportLineVATSpec.FindFirst(); + ExpenseReportLineVATSpec."Expense Category" := VATSpecExpenseCategory.Code; + ExpenseReportLineVATSpec."Expense Subcategory" := ''; + ExpenseReportLineVATSpec.Modify(); + ExpenseReportHeader.PerformManualRelease(); + VATSpecExpenseCategory.Validate(Inactive, true); + VATSpecExpenseCategory.Modify(true); + + // [WHEN] Posting is attempted. + asserterror PostExpenseReportWithConfirmation(ExpenseReportPost, ExpenseReportHeader); + + // [THEN] The inactive VAT-spec category blocks posting. + Assert.ExpectedTestFieldError(VATSpecExpenseCategory.FieldCaption(Inactive), Format(false)); + LibraryVariableStorage.AssertEmpty(); + end; + + [Test] + [HandlerFunctions('ExpensesModalPageHandler,ConfirmHandler')] + procedure InactiveVATSpecExpenseSubcategoryBlocksPosting() + var + Expense: Record Expense; + ExpenseCategory: Record "Expense Category"; + ExpenseItemization: Record "Expense Itemization"; + ExpenseReportHeader: Record "Expense Report Header"; + ExpenseSubcategory: array[2] of Record "Expense Subcategory"; + ExpenseUser: Record "Expense User"; + VATPostingSetup: Record "VAT Posting Setup"; + CreateExpenseReport: Codeunit "Create Expense Report"; + ExpenseReportPost: Codeunit "Expense Report-Post"; + begin + // [SCENARIO] Posting is blocked when a VAT specification references an inactive expense subcategory. + Initialize(); + + // [GIVEN] An expense report whose parent line references an active subcategory. + CreateExpenseUserAndCategory(ExpenseUser, ExpenseCategory); + CreateSubcategoryWithVATRate(ExpenseSubcategory[1], ExpenseCategory.Code, 20, VATPostingSetup); + CreateSubcategoryWithVATRate(ExpenseSubcategory[2], ExpenseCategory.Code, 20, VATPostingSetup); + CreateExpenseForHotel(Expense, ExpenseUser, ExpenseCategory, VATPostingSetup, 120); + LibraryExpense.CreateExpenseItemization(ExpenseItemization, Expense, ExpenseCategory.Code, ExpenseSubcategory[2].Code, WorkDate(), 120, 1); + CreateExpenseVATSpecification(Expense, ExpenseCategory.Code, ExpenseSubcategory[2].Code, 120); + ReleaseExpenseAndUpdateAccounts(Expense, ExpenseUser); + + LibraryExpense.CreateExpenseReport(ExpenseReportHeader, ExpenseUser."No.", '', Expense."VAT Bus. Posting Group"); + AddExpensesToReport(CreateExpenseReport, ExpenseReportHeader, Expense."No."); + UpdateExpenseReportLinesWithVendor(ExpenseReportHeader); + ExpenseReportHeader.PerformManualRelease(); + + // [GIVEN] The subcategory referenced only by the VAT specification becomes inactive. + ExpenseSubcategory[2].Validate(Inactive, true); + ExpenseSubcategory[2].Modify(true); + + // [WHEN] Posting is attempted. + asserterror PostExpenseReportWithConfirmation(ExpenseReportPost, ExpenseReportHeader); + + // [THEN] The inactive VAT-spec subcategory blocks posting. + Assert.ExpectedTestFieldError(ExpenseSubcategory[2].FieldCaption(Inactive), Format(false)); + LibraryVariableStorage.AssertEmpty(); + end; + [Test] [HandlerFunctions('ExpensesModalPageHandler,ConfirmHandler')] procedure ModifiedVATSpecIsRecalculatedAndPostedInReimbursementCurrency() @@ -675,6 +771,7 @@ codeunit 148330 "Expense Posting VAT Test" end; [Test] + [TransactionModel(TransactionModel::AutoCommit)] procedure AgentVATSpecificationCannotBeUpdatedModifiedOrDeleted() var Expense: Record Expense; @@ -683,6 +780,7 @@ codeunit 148330 "Expense Posting VAT Test" ExpenseUser: Record "Expense User"; ExpenseVATSpecification: Record "Expense VAT Specification"; VATPostingSetup: Record "VAT Posting Setup"; + ExpenseAgentAPIValidation: Codeunit "Expense Agent API Validation"; begin // [SCENARIO] Agent-authored VAT specifications cannot be regenerated, modified, or deleted. Initialize(); @@ -698,6 +796,7 @@ codeunit 148330 "Expense Posting VAT Test" ExpenseVATSpecification."Line No." := 1; ExpenseVATSpecification.Source := ExpenseVATSpecification.Source::Agent; ExpenseVATSpecification.Amount := 120; + ExpenseAgentAPIValidation.AuthorizeAgentVATSpecificationInsert(); ExpenseVATSpecification.Insert(true); Commit(); @@ -718,6 +817,30 @@ codeunit 148330 "Expense Posting VAT Test" Assert.AreEqual(120, ExpenseVATSpecification.Amount, 'The Agent-authored VAT specification must remain unchanged.'); end; + [Test] + procedure AgentVATSpecificationCannotBeInsertedWithoutAuthorization() + var + ExpenseVATSpecification: Record "Expense VAT Specification"; + begin + // [SCENARIO] Agent-authored VAT specifications require explicit table-layer authorization. + Initialize(); + + // [GIVEN] A VAT specification marked as agent-authored without an authorized agent request. + ExpenseVATSpecification.Init(); + ExpenseVATSpecification.Source := ExpenseVATSpecification.Source::Agent; + + // [WHEN] The VAT specification is inserted. + asserterror ExpenseVATSpecification.Insert(true); + + // [THEN] The table rejects the trusted source data. + Assert.ExpectedError(AgentVATSpecInsertNotAuthorizedErr); + + Clear(ExpenseVATSpecification); + ExpenseVATSpecification.Source := ExpenseVATSpecification.Source::Agent; + asserterror ExpenseVATSpecification.Insert(false); + Assert.ExpectedError(AgentVATSpecInsertNotAuthorizedErr); + end; + [Test] procedure UpdateVATSpecificationAggregatesItemizationsBySubcategory() var @@ -835,8 +958,7 @@ codeunit 148330 "Expense Posting VAT Test" VATSetup: Record "VAT Setup"; LibraryERMCountryData: Codeunit "Library - ERM Country Data"; begin - Clear(ExpectedExpenseNo); - Clear(ExpectedExpenseReportNo); + LibraryVariableStorage.Clear(); LibraryTestInitialize.OnTestInitialize(Codeunit::"Expense Posting VAT Test"); LibraryExpense.CleanUpBeforeTesting(); LibraryExpense.CleanTransactionalData(); @@ -991,16 +1113,17 @@ codeunit 148330 "Expense Posting VAT Test" local procedure AddExpensesToReport(var CreateExpenseReport: Codeunit "Create Expense Report"; ExpenseReportHeader: Record "Expense Report Header"; ExpenseNo: Code[20]) begin - ExpectedExpenseNo := ExpenseNo; + LibraryVariableStorage.Enqueue(ExpenseNo); CreateExpenseReport.AddExpensesToReport(ExpenseReportHeader); - Assert.AreEqual('', ExpectedExpenseNo, 'The expected Expenses modal page was not handled.'); + LibraryVariableStorage.AssertEmpty(); end; local procedure PostExpenseReportWithConfirmation(var ExpenseReportPost: Codeunit "Expense Report-Post"; var ExpenseReportHeader: Record "Expense Report Header") begin - ExpectedExpenseReportNo := ExpenseReportHeader."No."; + LibraryVariableStorage.Enqueue(StrSubstNo(PostExpenseReportQst, ExpenseReportHeader."No.")); + LibraryVariableStorage.Enqueue(true); ExpenseReportPost.PostExpenseReport(ExpenseReportHeader); - Assert.AreEqual('', ExpectedExpenseReportNo, 'The expected expense report posting confirmation was not handled.'); + LibraryVariableStorage.AssertEmpty(); end; local procedure GetRefundableDebitAccount(ExpenseCategoryCode: Code[20]): Code[20] @@ -1278,18 +1401,14 @@ codeunit 148330 "Expense Posting VAT Test" [ModalPageHandler] procedure ExpensesModalPageHandler(var Expenses: TestPage Expenses) begin - Assert.AreNotEqual('', ExpectedExpenseNo, 'An unexpected Expenses modal page was shown.'); - Expenses."No.".AssertEquals(ExpectedExpenseNo); - Clear(ExpectedExpenseNo); + Expenses."No.".AssertEquals(LibraryVariableStorage.DequeueText()); Expenses.OK().Invoke(); end; [ConfirmHandler] procedure ConfirmHandler(Question: Text[1024]; var Reply: Boolean) begin - Assert.AreNotEqual('', ExpectedExpenseReportNo, 'An unexpected confirmation dialog was shown.'); - Assert.AreEqual(StrSubstNo(PostExpenseReportQst, ExpectedExpenseReportNo), Question, 'The expense report posting confirmation is incorrect.'); - Clear(ExpectedExpenseReportNo); - Reply := true; + Assert.AreEqual(LibraryVariableStorage.DequeueText(), Question, 'The expense report posting confirmation is incorrect.'); + Reply := LibraryVariableStorage.DequeueBoolean(); end; } From 48eb7b9158098c0f24e42c5d8168acba78542ad0 Mon Sep 17 00:00:00 2001 From: Alexander Yakunin Date: Wed, 19 Aug 2026 18:07:01 +0200 Subject: [PATCH 14/29] Another round of review comments --- .../app/src/APIs/ExpenseVATSpecAPI.Page.al | 3 +- .../ExpenseAgentAPIValidation.Codeunit.al | 15 +-- .../Codeunits/ExpenseReportPost.Codeunit.al | 9 +- .../UpgradeExpReportVATSpec.Codeunit.al | 7 ++ .../src/ExpensePostingVATTest.Codeunit.al | 91 +++++++++++++------ .../test/src/ExpenseRuleTest.Codeunit.al | 5 + .../test/src/LibraryExpense.Codeunit.al | 2 + 7 files changed, 90 insertions(+), 42 deletions(-) diff --git a/src/Apps/W1/ExpenseAgent/app/src/APIs/ExpenseVATSpecAPI.Page.al b/src/Apps/W1/ExpenseAgent/app/src/APIs/ExpenseVATSpecAPI.Page.al index fb8027e74c1..dae0efc7123 100644 --- a/src/Apps/W1/ExpenseAgent/app/src/APIs/ExpenseVATSpecAPI.Page.al +++ b/src/Apps/W1/ExpenseAgent/app/src/APIs/ExpenseVATSpecAPI.Page.al @@ -131,8 +131,9 @@ page 7085 "Expense VAT Spec. API" begin VerifyExpenseAgentCaller(); ExpenseVATSpecification.Source := ExpenseVATSpecification.Source::Agent; - ExpenseAgentAPIValidation.AuthorizeAgentVATSpecificationInsert(); + BindSubscription(ExpenseAgentAPIValidation); ExpenseVATSpecification.Insert(true); + UnbindSubscription(ExpenseAgentAPIValidation); TelemetryDimensions.Add('HasExpenseCategory', Format(ExpenseVATSpecification."Expense Category" <> '')); TelemetryDimensions.Add('HasExpenseSubcategory', Format(ExpenseVATSpecification."Expense Subcategory" <> '')); diff --git a/src/Apps/W1/ExpenseAgent/app/src/Common/ExpenseAgentAPIValidation.Codeunit.al b/src/Apps/W1/ExpenseAgent/app/src/Common/ExpenseAgentAPIValidation.Codeunit.al index c753185572e..e0019d42797 100644 --- a/src/Apps/W1/ExpenseAgent/app/src/Common/ExpenseAgentAPIValidation.Codeunit.al +++ b/src/Apps/W1/ExpenseAgent/app/src/Common/ExpenseAgentAPIValidation.Codeunit.al @@ -11,35 +11,24 @@ using System.Environment.Configuration; codeunit 6993 "Expense Agent API Validation" { Access = Internal; + EventSubscriberInstance = Manual; InherentEntitlements = X; InherentPermissions = X; - SingleInstance = true; var AgentNotEnabledErr: Label 'Expense Agent is not enabled. Please contact your administrator.'; CapabilityNotEnabledErr: Label 'The "%1" capability is not enabled. Please contact your administrator to enable the capability.', Comment = '%1 = a capability name, such as Expense Agent'; - AgentVATSpecInsertAuthorized: Boolean; AgentVATSpecInsertNotAuthorizedErr: Label 'Agent-authored VAT specifications must be created through an authorized Expense Agent request.'; ExpenseAgentAadAppIdTxt: Label 'ee1eb5fd-719b-44f2-97d0-0efd34bc4148', Locked = true; - procedure AuthorizeAgentVATSpecificationInsert() - begin - if not IsCurrentUserExpenseAgent() then - Error(AgentVATSpecInsertNotAuthorizedErr); - - AgentVATSpecInsertAuthorized := true; - end; - [EventSubscriber(ObjectType::Table, Database::"Expense VAT Specification", OnBeforeInsertEvent, '', false, false)] local procedure CheckAgentVATSpecificationInsert(var Rec: Record "Expense VAT Specification"; RunTrigger: Boolean) begin if Rec.IsTemporary() or (Rec.Source <> Rec.Source::Agent) then exit; - if not AgentVATSpecInsertAuthorized then + if not IsCurrentUserExpenseAgent() then Error(AgentVATSpecInsertNotAuthorizedErr); - - AgentVATSpecInsertAuthorized := false; end; procedure VerifyAgentAccess() 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 9ec56ad8c6b..64bfa4a923d 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 @@ -75,6 +75,8 @@ codeunit 6987 "Expense Report-Post" NoNoreplyAccountErr: Label 'No account is set for sending emails. Set the send mail account for the Expense Agent before sending reimbursement notifications.'; NotApprovedForVATReclaimCategoryErr: Label 'VAT Reclaim Status is not set for Line with Expense Category %1.', Comment = '%1 = Expense Category'; NotApprovedForVATReclaimErr: Label 'VAT Reclaim Status is not set for Line with Expense Category %1 and Expense Subcategory %2.', Comment = '%1 = Expense Category, %2 = Expense Subcategory'; + PendingVATSpecTitleErr: Label 'VAT reclaim status is pending'; + PendingVATSpecDetailedErr: Label 'Open the VAT specification line and approve or reject the reclaim, and then retry posting the expense report.'; AgentVATSpecificationsPostedLbl: Label 'Agent-authored VAT specifications posted.', Locked = true; ShowItLbl: Label 'Show it'; @@ -302,7 +304,9 @@ codeunit 6987 "Expense Report-Post" PendingVATSpecErrorInfo.Message := StrSubstNo(NotApprovedForVATReclaimCategoryErr, ExpenseReportLineVATSpec."Expense Category") else PendingVATSpecErrorInfo.Message := StrSubstNo(NotApprovedForVATReclaimErr, ExpenseReportLineVATSpec."Expense Category", ExpenseReportLineVATSpec."Expense Subcategory"); - PendingVATSpecErrorInfo.DataClassification := DataClassification::SystemMetadata; + PendingVATSpecErrorInfo.Title := PendingVATSpecTitleErr; + PendingVATSpecErrorInfo.DetailedMessage := PendingVATSpecDetailedErr; + PendingVATSpecErrorInfo.DataClassification := DataClassification::CustomerContent; PendingVATSpecErrorInfo.ErrorType := ErrorType::Client; PendingVATSpecErrorInfo.RecordId := ExpenseReportLineVATSpec.RecordId; PendingVATSpecErrorInfo.FieldNo := ExpenseReportLineVATSpec.FieldNo("Reclaim Status"); @@ -1355,16 +1359,19 @@ codeunit 6987 "Expense Report-Post" ExpenseReportLineVATSpec.DeleteAll(); end; + [CommitBehavior(CommitBehavior::Ignore)] [IntegrationEvent(false, false)] local procedure OnAfterProcessExpenseReportLine(ExpenseReportHeader: Record "Expense Report Header"; ExpenseReportLine: Record "Expense Report Line"; PostedExpenseReportLine: Record "Posted Expense Report Line"; PostedExpenseReportHeader: Record "Posted Expense Report Header") begin end; + [CommitBehavior(CommitBehavior::Ignore)] [IntegrationEvent(false, false)] local procedure OnBeforePostEmployeeEntry(var GenJournalLine: Record "Gen. Journal Line"; ExpenseReportHeader: Record "Expense Report Header"; PostedExpenseReportHeader: Record "Posted Expense Report Header") begin end; + [CommitBehavior(CommitBehavior::Ignore)] [IntegrationEvent(false, false)] local procedure OnAfterPostEmployeeEntry(GenJournalLine: Record "Gen. Journal Line"; ExpenseReportHeader: Record "Expense Report Header"; PostedExpenseReportHeader: Record "Posted Expense Report Header") begin diff --git a/src/Apps/W1/ExpenseAgent/app/src/ExpenseReport/Codeunits/UpgradeExpReportVATSpec.Codeunit.al b/src/Apps/W1/ExpenseAgent/app/src/ExpenseReport/Codeunits/UpgradeExpReportVATSpec.Codeunit.al index eb47771f00e..4a97d78329c 100644 --- a/src/Apps/W1/ExpenseAgent/app/src/ExpenseReport/Codeunits/UpgradeExpReportVATSpec.Codeunit.al +++ b/src/Apps/W1/ExpenseAgent/app/src/ExpenseReport/Codeunits/UpgradeExpReportVATSpec.Codeunit.al @@ -78,6 +78,10 @@ codeunit 7105 "Upgrade Exp. Report VAT Spec" HeaderFound: Boolean; begin ExpenseReportLineVATSpec.SetCurrentKey("Document No.", "Document Line No.", "Line No."); + ExpenseReportLineVATSpec.SetLoadFields( + "Document No.", "Currency Code", "VAT Base Amount (LCY)", "VAT Amount", "VAT Amount (LCY)", "Reclaim %", + "VAT Base Amount (RCY)", "VAT Amount (RCY)", "Amount (RCY)", "Reclaim VAT Amount", + "Reclaim VAT Amount (LCY)", "Reclaim VAT Amount (RCY)"); if not ExpenseReportLineVATSpec.FindSet() then exit; @@ -121,6 +125,9 @@ codeunit 7105 "Upgrade Exp. Report VAT Spec" HeaderFound: Boolean; begin PostedExpenseReportLineVATSpec.SetCurrentKey("Expense Report No.", "Expense Report Line No.", "Line No."); + PostedExpenseReportLineVATSpec.SetLoadFields( + "Expense Report No.", "VAT Base Amount (LCY)", "VAT Amount (LCY)", "Reclaim %", + "VAT Base Amount (RCY)", "VAT Amount (RCY)", "Amount (RCY)", "Reclaim VAT Amount (RCY)"); if not PostedExpenseReportLineVATSpec.FindSet() then exit; diff --git a/src/Apps/W1/ExpenseAgent/test/src/ExpensePostingVATTest.Codeunit.al b/src/Apps/W1/ExpenseAgent/test/src/ExpensePostingVATTest.Codeunit.al index 7823c7e871d..8dc4e507f83 100644 --- a/src/Apps/W1/ExpenseAgent/test/src/ExpensePostingVATTest.Codeunit.al +++ b/src/Apps/W1/ExpenseAgent/test/src/ExpensePostingVATTest.Codeunit.al @@ -28,10 +28,10 @@ codeunit 148330 "Expense Posting VAT Test" LibraryUtility: Codeunit "Library - Utility"; LibraryVariableStorage: Codeunit "Library - Variable Storage"; IsInitialized: Boolean; + CanModifyLinesQst: Label 'You have modified %1 which will also update the lines.\\Do you want to continue?', Comment = '%1 = Field Caption'; PostExpenseReportQst: Label 'Do you want to post Expense Report %1?', Comment = '%1 = Expense Report No.'; NotApprovedForVATReclaimCategoryErr: Label 'VAT Reclaim Status is not set for Line with Expense Category %1.', Comment = '%1 = Expense Category'; NotApprovedForVATReclaimErr: Label 'VAT Reclaim Status is not set for Line with Expense Category %1 and Expense Subcategory %2.', Comment = '%1 = Expense Category, %2 = Expense Subcategory'; - AgentVATSpecInsertNotAuthorizedErr: Label 'Agent-authored VAT specifications must be created through an authorized Expense Agent request.'; ModifyOrDeleteAgentVATSpecErr: Label 'Modifications and delete are not allowed for records created by the Expense Agent API.'; [Test] @@ -630,6 +630,59 @@ codeunit 148330 "Expense Posting VAT Test" Assert.AreNearlyEqual(ExpectedVATAmountRCY, GLEntry."Source Currency VAT Amount", 0.01, 'Posted source currency VAT amount must use the VAT specification reimbursement amount.'); end; + [Test] + [HandlerFunctions('ExpensesModalPageHandler,ConfirmHandler')] + procedure ReopenedReportRecalculatesVATSpecWhenReimbursementCurrencyFactorChanges() + var + Expense: Record Expense; + ExpenseUser: Record "Expense User"; + ExpenseCategory: Record "Expense Category"; + ExpenseSubCategory: array[4] of Record "Expense Subcategory"; + ExpenseReportHeader: Record "Expense Report Header"; + ExpenseReportLineVATSpec: Record "Expense Report Line VAT Spec."; + VATPostingSetup: array[3] of Record "VAT Posting Setup"; + CreateExpenseReport: Codeunit "Create Expense Report"; + CurrencyCode: Code[10]; + InitialVATBaseAmountRCY: Decimal; + InitialVATAmountRCY: Decimal; + begin + // [SCENARIO] Changing the reimbursement currency factor on a reopened report recalculates existing VAT specifications. + Initialize(); + + // [GIVEN] A released report with an existing 20% VAT specification calculated at a reimbursement factor of 1. + CreateExpenseUserAndCategory(ExpenseUser, ExpenseCategory); + CreateSubcategoryWithVATRate(ExpenseSubCategory[1], ExpenseCategory.Code, 20, VATPostingSetup[1]); + CreateExpenseWithHotelItemizations(Expense, ExpenseUser, ExpenseCategory, ExpenseSubCategory, VATPostingSetup, 120, 0, 0, 0); + CurrencyCode := LibraryERM.CreateCurrencyWithExchangeRate(WorkDate(), 1, 1); + LibraryExpense.CreateExpenseReport(ExpenseReportHeader, ExpenseUser."No.", CurrencyCode, Expense."VAT Bus. Posting Group"); + AddExpensesToReport(CreateExpenseReport, ExpenseReportHeader, Expense."No."); + UpdateExpenseReportLinesWithVendor(ExpenseReportHeader); + + ExpenseReportLineVATSpec.SetRange("Document No.", ExpenseReportHeader."No."); + ExpenseReportLineVATSpec.FindFirst(); + InitialVATBaseAmountRCY := ExpenseReportLineVATSpec."VAT Base Amount (RCY)"; + InitialVATAmountRCY := ExpenseReportLineVATSpec."VAT Amount (RCY)"; + + ExpenseReportHeader.PerformManualRelease(); + ExpenseReportHeader.PerformManualReopen(ExpenseReportHeader); + ExpenseReportHeader.Get(ExpenseReportHeader."No."); + + // [WHEN] The reimbursement currency factor is changed to 2 after reopening the report. + LibraryVariableStorage.Enqueue(StrSubstNo(CanModifyLinesQst, ExpenseReportHeader.FieldName("Reimbursement Currency Factor"))); + LibraryVariableStorage.Enqueue(true); + ExpenseReportHeader.Validate("Reimbursement Currency Factor", 2); + ExpenseReportHeader.Modify(true); + LibraryVariableStorage.AssertEmpty(); + + // [THEN] The persisted VAT specification reimbursement amounts are recalculated using the new factor. + ExpenseReportLineVATSpec.Get( + ExpenseReportLineVATSpec."Document No.", ExpenseReportLineVATSpec."Document Line No.", ExpenseReportLineVATSpec."Line No."); + Assert.AreNearlyEqual(InitialVATBaseAmountRCY * 2, ExpenseReportLineVATSpec."VAT Base Amount (RCY)", 0.01, 'VAT base amount in reimbursement currency must be recalculated.'); + Assert.AreNearlyEqual(InitialVATAmountRCY * 2, ExpenseReportLineVATSpec."VAT Amount (RCY)", 0.01, 'VAT amount in reimbursement currency must be recalculated.'); + Assert.AreNearlyEqual((InitialVATBaseAmountRCY + InitialVATAmountRCY) * 2, ExpenseReportLineVATSpec."Amount (RCY)", 0.01, 'Gross amount in reimbursement currency must be recalculated.'); + Assert.AreNearlyEqual(InitialVATAmountRCY * 2, ExpenseReportLineVATSpec."Reclaim VAT Amount (RCY)", 0.01, 'Reclaim VAT amount in reimbursement currency must be recalculated.'); + end; + [Test] [HandlerFunctions('ExpensesModalPageHandler,ConfirmHandler')] procedure PartialVATReclaimPostsReclaimAmountsAndNonDeductibleVAT() @@ -780,7 +833,6 @@ codeunit 148330 "Expense Posting VAT Test" ExpenseUser: Record "Expense User"; ExpenseVATSpecification: Record "Expense VAT Specification"; VATPostingSetup: Record "VAT Posting Setup"; - ExpenseAgentAPIValidation: Codeunit "Expense Agent API Validation"; begin // [SCENARIO] Agent-authored VAT specifications cannot be regenerated, modified, or deleted. Initialize(); @@ -791,12 +843,15 @@ codeunit 148330 "Expense Posting VAT Test" CreateSubcategoryWithVATRate(ExpenseSubCategory, ExpenseCategory.Code, 20, VATPostingSetup); LibraryExpense.CreateExpense(Expense, ExpenseUser."No.", ExpenseCategory.Code, '', '', true, '', 120); + ExpenseVATSpecification.SetRange("Expense No.", Expense."No."); + ExpenseVATSpecification.DeleteAll(false); ExpenseVATSpecification.Init(); ExpenseVATSpecification."Expense No." := Expense."No."; ExpenseVATSpecification."Line No." := 1; ExpenseVATSpecification.Source := ExpenseVATSpecification.Source::Agent; + ExpenseVATSpecification."VAT Bus. Posting Group" := VATPostingSetup."VAT Bus. Posting Group"; + ExpenseVATSpecification."VAT Prod. Posting Group" := VATPostingSetup."VAT Prod. Posting Group"; ExpenseVATSpecification.Amount := 120; - ExpenseAgentAPIValidation.AuthorizeAgentVATSpecificationInsert(); ExpenseVATSpecification.Insert(true); Commit(); @@ -815,30 +870,7 @@ codeunit 148330 "Expense Posting VAT Test" ExpenseVATSpecification.Get(Expense."No.", 1); Assert.AreEqual(ExpenseVATSpecification.Source::Agent, ExpenseVATSpecification.Source, 'The VAT specification source must remain Agent.'); Assert.AreEqual(120, ExpenseVATSpecification.Amount, 'The Agent-authored VAT specification must remain unchanged.'); - end; - - [Test] - procedure AgentVATSpecificationCannotBeInsertedWithoutAuthorization() - var - ExpenseVATSpecification: Record "Expense VAT Specification"; - begin - // [SCENARIO] Agent-authored VAT specifications require explicit table-layer authorization. - Initialize(); - - // [GIVEN] A VAT specification marked as agent-authored without an authorized agent request. - ExpenseVATSpecification.Init(); - ExpenseVATSpecification.Source := ExpenseVATSpecification.Source::Agent; - - // [WHEN] The VAT specification is inserted. - asserterror ExpenseVATSpecification.Insert(true); - - // [THEN] The table rejects the trusted source data. - Assert.ExpectedError(AgentVATSpecInsertNotAuthorizedErr); - - Clear(ExpenseVATSpecification); - ExpenseVATSpecification.Source := ExpenseVATSpecification.Source::Agent; - asserterror ExpenseVATSpecification.Insert(false); - Assert.ExpectedError(AgentVATSpecInsertNotAuthorizedErr); + ExpenseVATSpecification.Delete(false); end; [Test] @@ -1212,6 +1244,7 @@ codeunit 148330 "Expense Posting VAT Test" local procedure CreateExpenseVATSpecification(Expense: Record Expense; ExpenseCategoryCode: Code[20]; ExpenseSubcategoryCode: Code[20]; Amount: Decimal) var ExpenseVATSpecification: Record "Expense VAT Specification"; + ExpenseSubcategory: Record "Expense Subcategory"; RecordRef: RecordRef; begin ExpenseVATSpecification.Init(); @@ -1222,6 +1255,10 @@ codeunit 148330 "Expense Posting VAT Test" ExpenseVATSpecification.Validate("VAT Bus. Posting Group", Expense."VAT Bus. Posting Group"); ExpenseVATSpecification.Validate("Expense Category", ExpenseCategoryCode); ExpenseVATSpecification.Validate("Expense Subcategory", ExpenseSubcategoryCode); + if ExpenseSubcategoryCode <> '' then begin + ExpenseSubcategory.Get(ExpenseCategoryCode, ExpenseSubcategoryCode); + ExpenseVATSpecification.Validate("VAT Prod. Posting Group", ExpenseSubcategory."VAT Prod. Posting Group"); + end; ExpenseVATSpecification.Validate(Amount, Amount); ExpenseVATSpecification.Insert(true); end; diff --git a/src/Apps/W1/ExpenseAgent/test/src/ExpenseRuleTest.Codeunit.al b/src/Apps/W1/ExpenseAgent/test/src/ExpenseRuleTest.Codeunit.al index 23af114f889..8e3bd4e6932 100644 --- a/src/Apps/W1/ExpenseAgent/test/src/ExpenseRuleTest.Codeunit.al +++ b/src/Apps/W1/ExpenseAgent/test/src/ExpenseRuleTest.Codeunit.al @@ -8442,11 +8442,16 @@ codeunit 148301 "Expense Rule Test" local procedure Initialize() var + ExpenseAgentSetup: Record "Expense Agent Setup"; Workflow: Record Workflow; LibraryERMCountryData: Codeunit "Library - ERM Country Data"; begin LibraryTestInitialize.OnTestInitialize(Codeunit::"Expense Rule Test"); + LibraryExpense.CleanTransactionalData(); LibraryExpense.CleanUpBeforeTesting(); + ExpenseAgentSetup.Get(); + ExpenseAgentSetup."Default VAT Bus. Posting Group" := ''; + ExpenseAgentSetup.Modify(); LibraryVariableStorage.Clear(); if IsInitialized then exit; diff --git a/src/Apps/W1/ExpenseAgent/test/src/LibraryExpense.Codeunit.al b/src/Apps/W1/ExpenseAgent/test/src/LibraryExpense.Codeunit.al index f686afbb501..e4db89e25d5 100644 --- a/src/Apps/W1/ExpenseAgent/test/src/LibraryExpense.Codeunit.al +++ b/src/Apps/W1/ExpenseAgent/test/src/LibraryExpense.Codeunit.al @@ -607,6 +607,7 @@ codeunit 148300 "Library - Expense" ExpenseItemization: Record "Expense Itemization"; ExpenseParticipant: Record "Expense Participant"; ExpensePerDiem: Record "Expense Per Diem"; + ExpenseVATSpecification: Record "Expense VAT Specification"; ExpenseReportHeader: Record "Expense Report Header"; ExpenseReportLine: Record "Expense Report Line"; ExpenseReportLineItem: Record "Expense Report Line Item"; @@ -632,6 +633,7 @@ codeunit 148300 "Library - Expense" ExpenseParticipant.DeleteAll(); ExpenseItemization.DeleteAll(); ExpensePerDiem.DeleteAll(); + ExpenseVATSpecification.DeleteAll(false); Expense.DeleteAll(); ExpenseRuleViolation.DeleteAll(); From cc3def845c180c02f8f8ccde96c4af6a545a9117 Mon Sep 17 00:00:00 2001 From: Alexander Yakunin Date: Thu, 20 Aug 2026 13:10:35 +0200 Subject: [PATCH 15/29] Fix VAT Non Deductible tests --- .../Codeunits/ExpenseReportPost.Codeunit.al | 25 ++++++++++++++----- .../src/ExpensePostingVATTest.Codeunit.al | 4 +-- 2 files changed, 21 insertions(+), 8 deletions(-) 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 64bfa4a923d..b4daabf00c4 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 @@ -557,6 +557,7 @@ codeunit 6987 "Expense Report-Post" Clear(GenJournalLine); CreateGenJournalLineFromSpec(GenJournalLine, ExpenseReportHeader, ExpenseReportLine, ExpenseReportLineVATSpec, PostedExpReportLine); SetupRefundableAccountForSpec(GenJournalLine, ExpenseReportLine, ExpenseReportLineVATSpec); + SetupNonDeductibleVATForSpec(GenJournalLine, ExpenseReportLineVATSpec); SetupSourceCodeAndDimensions(GenJournalLine, ExpenseReportLine."Dimension Set ID"); if (not JobLedgEntryPosted) and (ExpenseReportLine."Job No." <> '') then begin @@ -840,12 +841,6 @@ codeunit 6987 "Expense Report-Post" GenJournalLine."Source Curr. VAT Base Amount" := ExpenseReportLineVATSpec."VAT Base Amount (RCY)"; GenJournalLine."Source Curr. VAT Amount" := ExpenseReportLineVATSpec."VAT Amount (RCY)"; - // VAT Reclaim % is used to route the non-deductible portion of VAT to the correct accounts via VAT Posting Setup. - if (ExpenseReportLineVATSpec."Reclaim %" <> 100) and (ExpenseReportLineVATSpec."VAT Amount (LCY)" <> 0) then begin - VATSetup.Get(); - VATSetup.TestField("Non-Deductible VAT Is Enabled"); - GenJournalLine.Validate("Non-Deductible VAT %", 100 - ExpenseReportLineVATSpec."Reclaim %"); - end; end else begin // VAT is not reclaimable: include VAT in the expense amount (gross) and do not create a VAT entry. GenJournalLine.Amount := ExpenseReportLineVATSpec."VAT Base Amount (RCY)" + ExpenseReportLineVATSpec."VAT Amount (RCY)"; @@ -874,6 +869,24 @@ codeunit 6987 "Expense Report-Post" GenJournalLine."System-Created Entry" := true; end; + local procedure SetupNonDeductibleVATForSpec(var GenJournalLine: Record "Gen. Journal Line"; ExpenseReportLineVATSpec: Record "Expense Report Line VAT Spec.") + begin + if ExpenseReportLineVATSpec."Reclaim Status" <> ExpenseReportLineVATSpec."Reclaim Status"::Approved then + exit; + if (ExpenseReportLineVATSpec."Reclaim %" = 100) or (ExpenseReportLineVATSpec."VAT Amount (LCY)" = 0) then + exit; + + VATSetup.Get(); + VATSetup.TestField("Non-Deductible VAT Is Enabled"); + GenJournalLine.Validate("Non-Deductible VAT %", 100 - ExpenseReportLineVATSpec."Reclaim %"); + GenJournalLine.Validate("Non-Deductible VAT Base", ExpenseReportLineVATSpec."VAT Base Amount (RCY)" * (100 - ExpenseReportLineVATSpec."Reclaim %") / 100); + GenJournalLine.Validate("Non-Deductible VAT Amount", ExpenseReportLineVATSpec."VAT Amount (RCY)" - ExpenseReportLineVATSpec."Reclaim VAT Amount (RCY)"); + GenJournalLine.Validate("Non-Deductible VAT Base LCY", ExpenseReportLineVATSpec."VAT Base Amount (LCY)" * (100 - ExpenseReportLineVATSpec."Reclaim %") / 100); + GenJournalLine.Validate("Non-Deductible VAT Amount LCY", ExpenseReportLineVATSpec."VAT Amount (LCY)" - ExpenseReportLineVATSpec."Reclaim VAT Amount (LCY)"); + GenJournalLine."Amount (LCY)" -= GenJournalLine."Non-Deductible VAT Amount LCY"; + GenJournalLine."Source Curr. VAT Amount" := ExpenseReportLineVATSpec."Reclaim VAT Amount (RCY)"; + end; + local procedure SetupRefundableAccount(var GenJournalLine: Record "Gen. Journal Line"; ExpenseReportHeader: Record "Expense Report Header"; ExpenseReportLine: Record "Expense Report Line") var ExpenseCategory: Record "Expense Category"; diff --git a/src/Apps/W1/ExpenseAgent/test/src/ExpensePostingVATTest.Codeunit.al b/src/Apps/W1/ExpenseAgent/test/src/ExpensePostingVATTest.Codeunit.al index 8dc4e507f83..9b187579406 100644 --- a/src/Apps/W1/ExpenseAgent/test/src/ExpensePostingVATTest.Codeunit.al +++ b/src/Apps/W1/ExpenseAgent/test/src/ExpensePostingVATTest.Codeunit.al @@ -971,8 +971,8 @@ codeunit 148330 "Expense Posting VAT Test" VATEntry.SetRange("VAT Bus. Posting Group", VATPostingSetup[1]."VAT Bus. Posting Group"); VATEntry.SetRange("VAT Prod. Posting Group", VATPostingSetup[1]."VAT Prod. Posting Group"); VATEntry.CalcSums(Base, Amount, "Non-Deductible VAT Base", "Non-Deductible VAT Amount"); - Assert.AreNearlyEqual(ExpectedDeductibleBase, VATEntry.Base, 0.01, 'The VAT entry must contain the deductible VAT base.'); - Assert.AreNearlyEqual(ExpectedDeductibleVAT, VATEntry.Amount, 0.01, 'The VAT entry must contain the deductible VAT amount.'); + Assert.AreNearlyEqual(ExpectedDeductibleBase, VATEntry.Base - VATEntry."Non-Deductible VAT Base", 0.01, 'The VAT entry must contain the deductible VAT base.'); + Assert.AreNearlyEqual(ExpectedDeductibleVAT, VATEntry.Amount - VATEntry."Non-Deductible VAT Amount", 0.01, 'The VAT entry must contain the deductible VAT amount.'); Assert.AreNearlyEqual(ExpectedNonDeductibleBase, VATEntry."Non-Deductible VAT Base", 0.01, 'The VAT entry must contain the non-deductible VAT base.'); Assert.AreNearlyEqual(ExpectedNonDeductibleVAT, VATEntry."Non-Deductible VAT Amount", 0.01, 'The VAT entry must contain the non-deductible VAT amount.'); From 2b91b9dfbd694c10ac9d3aa09d8f04c9111e52f2 Mon Sep 17 00:00:00 2001 From: Alexander Yakunin Date: Thu, 20 Aug 2026 16:31:18 +0200 Subject: [PATCH 16/29] Fix ND VAT tests for BE IT NO --- .../Codeunits/ExpenseReportPost.Codeunit.al | 3 +-- .../app/src/Setup/Tables/ExpenseAgentSetup.Table.al | 2 +- .../test/src/ExpensePostingVATTest.Codeunit.al | 10 ++++------ 3 files changed, 6 insertions(+), 9 deletions(-) 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 b4daabf00c4..00735260b8c 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 @@ -877,13 +877,12 @@ codeunit 6987 "Expense Report-Post" exit; VATSetup.Get(); - VATSetup.TestField("Non-Deductible VAT Is Enabled"); + VATSetup.TestField("Enable Non-Deductible VAT"); GenJournalLine.Validate("Non-Deductible VAT %", 100 - ExpenseReportLineVATSpec."Reclaim %"); GenJournalLine.Validate("Non-Deductible VAT Base", ExpenseReportLineVATSpec."VAT Base Amount (RCY)" * (100 - ExpenseReportLineVATSpec."Reclaim %") / 100); GenJournalLine.Validate("Non-Deductible VAT Amount", ExpenseReportLineVATSpec."VAT Amount (RCY)" - ExpenseReportLineVATSpec."Reclaim VAT Amount (RCY)"); GenJournalLine.Validate("Non-Deductible VAT Base LCY", ExpenseReportLineVATSpec."VAT Base Amount (LCY)" * (100 - ExpenseReportLineVATSpec."Reclaim %") / 100); GenJournalLine.Validate("Non-Deductible VAT Amount LCY", ExpenseReportLineVATSpec."VAT Amount (LCY)" - ExpenseReportLineVATSpec."Reclaim VAT Amount (LCY)"); - GenJournalLine."Amount (LCY)" -= GenJournalLine."Non-Deductible VAT Amount LCY"; GenJournalLine."Source Curr. VAT Amount" := ExpenseReportLineVATSpec."Reclaim VAT Amount (RCY)"; end; diff --git a/src/Apps/W1/ExpenseAgent/app/src/Setup/Tables/ExpenseAgentSetup.Table.al b/src/Apps/W1/ExpenseAgent/app/src/Setup/Tables/ExpenseAgentSetup.Table.al index 38600f0e7e8..64d10d90bcc 100644 --- a/src/Apps/W1/ExpenseAgent/app/src/Setup/Tables/ExpenseAgentSetup.Table.al +++ b/src/Apps/W1/ExpenseAgent/app/src/Setup/Tables/ExpenseAgentSetup.Table.al @@ -656,7 +656,7 @@ table 6930 "Expense Agent Setup" if "Allow VAT Reclaim" then begin TestField("Default VAT Bus. Posting Group"); VATSetup.Get(); - VATSetup.TestField("Non-Deductible VAT Is Enabled"); + VATSetup.TestField("Enable Non-Deductible VAT"); end; end; } diff --git a/src/Apps/W1/ExpenseAgent/test/src/ExpensePostingVATTest.Codeunit.al b/src/Apps/W1/ExpenseAgent/test/src/ExpensePostingVATTest.Codeunit.al index 9b187579406..6b92aa79dd2 100644 --- a/src/Apps/W1/ExpenseAgent/test/src/ExpensePostingVATTest.Codeunit.al +++ b/src/Apps/W1/ExpenseAgent/test/src/ExpensePostingVATTest.Codeunit.al @@ -25,6 +25,7 @@ codeunit 148330 "Expense Posting VAT Test" LibraryExpense: Codeunit "Library - Expense"; LibraryPurchase: Codeunit "Library - Purchase"; LibraryTestInitialize: Codeunit "Library - Test Initialize"; + LibraryNonDeductibleVAT: Codeunit "Library - NonDeductible VAT"; LibraryUtility: Codeunit "Library - Utility"; LibraryVariableStorage: Codeunit "Library - Variable Storage"; IsInitialized: Boolean; @@ -971,8 +972,8 @@ codeunit 148330 "Expense Posting VAT Test" VATEntry.SetRange("VAT Bus. Posting Group", VATPostingSetup[1]."VAT Bus. Posting Group"); VATEntry.SetRange("VAT Prod. Posting Group", VATPostingSetup[1]."VAT Prod. Posting Group"); VATEntry.CalcSums(Base, Amount, "Non-Deductible VAT Base", "Non-Deductible VAT Amount"); - Assert.AreNearlyEqual(ExpectedDeductibleBase, VATEntry.Base - VATEntry."Non-Deductible VAT Base", 0.01, 'The VAT entry must contain the deductible VAT base.'); - Assert.AreNearlyEqual(ExpectedDeductibleVAT, VATEntry.Amount - VATEntry."Non-Deductible VAT Amount", 0.01, 'The VAT entry must contain the deductible VAT amount.'); + Assert.AreNearlyEqual(ExpectedDeductibleBase, VATEntry.Base, 0.01, 'The VAT entry must contain the deductible VAT base.'); + Assert.AreNearlyEqual(ExpectedDeductibleVAT, VATEntry.Amount, 0.01, 'The VAT entry must contain the deductible VAT amount.'); Assert.AreNearlyEqual(ExpectedNonDeductibleBase, VATEntry."Non-Deductible VAT Base", 0.01, 'The VAT entry must contain the non-deductible VAT base.'); Assert.AreNearlyEqual(ExpectedNonDeductibleVAT, VATEntry."Non-Deductible VAT Amount", 0.01, 'The VAT entry must contain the non-deductible VAT amount.'); @@ -987,7 +988,6 @@ codeunit 148330 "Expense Posting VAT Test" var ExpenseAgentSetup: Record "Expense Agent Setup"; VATBusinessPostingGroup: Record "VAT Business Posting Group"; - VATSetup: Record "VAT Setup"; LibraryERMCountryData: Codeunit "Library - ERM Country Data"; begin LibraryVariableStorage.Clear(); @@ -1004,9 +1004,7 @@ codeunit 148330 "Expense Posting VAT Test" ExpenseAgentSetup.Validate("Default VAT Bus. Posting Group", VATBusinessPostingGroup.Code); ExpenseAgentSetup.Modify(true); - VATSetup.Get(); - VATSetup.Validate("Non-Deductible VAT Is Enabled", true); - VATSetup.Modify(true); + LibraryNonDeductibleVAT.EnableNonDeductibleVAT(); LibraryERMCountryData.UpdateJournalTemplMandatory(false); LibraryERMCountryData.UpdateLocalData(); From ce0aaa0b492a02647fae118d4875256e9535cd07 Mon Sep 17 00:00:00 2001 From: Alexander Yakunin Date: Fri, 21 Aug 2026 11:14:44 +0200 Subject: [PATCH 17/29] Next round of code review comments --- .../app/src/APIs/ExpenseVATSpecAPI.Page.al | 10 +- .../ExpenseAgentAPIValidation.Codeunit.al | 12 +- .../app/src/Expense/Tables/Expense.Table.al | 2 + .../UpgradeExpReportVATSpec.Codeunit.al | 4 +- .../Pages/ExpenseReportLineVATSpec.Page.al | 2 + .../Tables/ExpenseReportLineVATSpec.Table.al | 7 +- .../src/ExpensePostingVATTest.Codeunit.al | 132 +++++++++++++++++- 7 files changed, 151 insertions(+), 18 deletions(-) diff --git a/src/Apps/W1/ExpenseAgent/app/src/APIs/ExpenseVATSpecAPI.Page.al b/src/Apps/W1/ExpenseAgent/app/src/APIs/ExpenseVATSpecAPI.Page.al index dae0efc7123..40d5202103a 100644 --- a/src/Apps/W1/ExpenseAgent/app/src/APIs/ExpenseVATSpecAPI.Page.al +++ b/src/Apps/W1/ExpenseAgent/app/src/APIs/ExpenseVATSpecAPI.Page.al @@ -125,15 +125,11 @@ page 7085 "Expense VAT Spec. API" internal procedure InsertVATSpecification(var ExpenseVATSpecification: Record "Expense VAT Specification") var ExpenseAgentSetup: Record "Expense Agent Setup"; - ExpenseAgentAPIValidation: Codeunit "Expense Agent API Validation"; FeatureTelemetry: Codeunit "Feature Telemetry"; TelemetryDimensions: Dictionary of [Text, Text]; begin - VerifyExpenseAgentCaller(); ExpenseVATSpecification.Source := ExpenseVATSpecification.Source::Agent; - BindSubscription(ExpenseAgentAPIValidation); ExpenseVATSpecification.Insert(true); - UnbindSubscription(ExpenseAgentAPIValidation); TelemetryDimensions.Add('HasExpenseCategory', Format(ExpenseVATSpecification."Expense Category" <> '')); TelemetryDimensions.Add('HasExpenseSubcategory', Format(ExpenseVATSpecification."Expense Subcategory" <> '')); @@ -141,14 +137,10 @@ page 7085 "Expense VAT Spec. API" FeatureTelemetry.LogUptake('0000UZ7', ExpenseAgentSetup.GetFeatureName(), Enum::"Feature Uptake Status"::Used, TelemetryDimensions); end; - var - ExpenseAgentCallerRequiredErr: Label 'Only the Expense Agent application can create agent-authored VAT specifications.'; - local procedure VerifyExpenseAgentCaller() var ExpenseAgentAPIValidation: Codeunit "Expense Agent API Validation"; begin - if not ExpenseAgentAPIValidation.IsCurrentUserExpenseAgent() then - Error(ExpenseAgentCallerRequiredErr); + ExpenseAgentAPIValidation.VerifyAgentVATSpecificationAccess(); end; } diff --git a/src/Apps/W1/ExpenseAgent/app/src/Common/ExpenseAgentAPIValidation.Codeunit.al b/src/Apps/W1/ExpenseAgent/app/src/Common/ExpenseAgentAPIValidation.Codeunit.al index e0019d42797..7817015899d 100644 --- a/src/Apps/W1/ExpenseAgent/app/src/Common/ExpenseAgentAPIValidation.Codeunit.al +++ b/src/Apps/W1/ExpenseAgent/app/src/Common/ExpenseAgentAPIValidation.Codeunit.al @@ -11,7 +11,7 @@ using System.Environment.Configuration; codeunit 6993 "Expense Agent API Validation" { Access = Internal; - EventSubscriberInstance = Manual; + EventSubscriberInstance = StaticAutomatic; InherentEntitlements = X; InherentPermissions = X; @@ -27,6 +27,16 @@ codeunit 6993 "Expense Agent API Validation" if Rec.IsTemporary() or (Rec.Source <> Rec.Source::Agent) then exit; + VerifyAgentVATSpecificationAccess(); + end; + + internal procedure VerifyAgentVATSpecificationAccess() + var + EnvironmentInfo: Codeunit "Environment Information"; + begin + if not EnvironmentInfo.IsSaaSInfrastructure() then + Error(AgentVATSpecInsertNotAuthorizedErr); + if not IsCurrentUserExpenseAgent() then Error(AgentVATSpecInsertNotAuthorizedErr); end; diff --git a/src/Apps/W1/ExpenseAgent/app/src/Expense/Tables/Expense.Table.al b/src/Apps/W1/ExpenseAgent/app/src/Expense/Tables/Expense.Table.al index dc099b9a911..ec04e024901 100644 --- a/src/Apps/W1/ExpenseAgent/app/src/Expense/Tables/Expense.Table.al +++ b/src/Apps/W1/ExpenseAgent/app/src/Expense/Tables/Expense.Table.al @@ -1368,6 +1368,8 @@ table 6900 Expense LineNo += 1; TempExpenseVATSpec."Line No." := LineNo; TempExpenseVATSpec.Validate("Expense Category", "Expense Category"); + if "Expense Subcategory" <> '' then + TempExpenseVATSpec.Validate("Expense Subcategory", "Expense Subcategory"); TempExpenseVATSpec.Validate("VAT Bus. Posting Group", ExpenseAgentSetup."Default VAT Bus. Posting Group"); TempExpenseVATSpec.Validate("Amount", Amount); TempExpenseVATSpec.Insert(); diff --git a/src/Apps/W1/ExpenseAgent/app/src/ExpenseReport/Codeunits/UpgradeExpReportVATSpec.Codeunit.al b/src/Apps/W1/ExpenseAgent/app/src/ExpenseReport/Codeunits/UpgradeExpReportVATSpec.Codeunit.al index 4a97d78329c..bfc2144d27d 100644 --- a/src/Apps/W1/ExpenseAgent/app/src/ExpenseReport/Codeunits/UpgradeExpReportVATSpec.Codeunit.al +++ b/src/Apps/W1/ExpenseAgent/app/src/ExpenseReport/Codeunits/UpgradeExpReportVATSpec.Codeunit.al @@ -82,7 +82,7 @@ codeunit 7105 "Upgrade Exp. Report VAT Spec" "Document No.", "Currency Code", "VAT Base Amount (LCY)", "VAT Amount", "VAT Amount (LCY)", "Reclaim %", "VAT Base Amount (RCY)", "VAT Amount (RCY)", "Amount (RCY)", "Reclaim VAT Amount", "Reclaim VAT Amount (LCY)", "Reclaim VAT Amount (RCY)"); - if not ExpenseReportLineVATSpec.FindSet() then + if not ExpenseReportLineVATSpec.FindSet(true) then exit; CachedDocumentNo := ''; @@ -128,7 +128,7 @@ codeunit 7105 "Upgrade Exp. Report VAT Spec" PostedExpenseReportLineVATSpec.SetLoadFields( "Expense Report No.", "VAT Base Amount (LCY)", "VAT Amount (LCY)", "Reclaim %", "VAT Base Amount (RCY)", "VAT Amount (RCY)", "Amount (RCY)", "Reclaim VAT Amount (RCY)"); - if not PostedExpenseReportLineVATSpec.FindSet() then + if not PostedExpenseReportLineVATSpec.FindSet(true) then exit; CachedExpenseReportNo := ''; diff --git a/src/Apps/W1/ExpenseAgent/app/src/ExpenseReport/Pages/ExpenseReportLineVATSpec.Page.al b/src/Apps/W1/ExpenseAgent/app/src/ExpenseReport/Pages/ExpenseReportLineVATSpec.Page.al index b7d490658d1..ec751fa48fc 100644 --- a/src/Apps/W1/ExpenseAgent/app/src/ExpenseReport/Pages/ExpenseReportLineVATSpec.Page.al +++ b/src/Apps/W1/ExpenseAgent/app/src/ExpenseReport/Pages/ExpenseReportLineVATSpec.Page.al @@ -53,10 +53,12 @@ page 7081 "Expense Report Line VAT Spec." field("VAT Base Amount"; Rec."VAT Base Amount") { Caption = 'VAT Base Amount'; + Editable = false; } field("VAT Amount"; Rec."VAT Amount") { Caption = 'VAT Amount'; + Editable = false; } field("Amount (LCY)"; Rec."Amount (LCY)") { diff --git a/src/Apps/W1/ExpenseAgent/app/src/ExpenseReport/Tables/ExpenseReportLineVATSpec.Table.al b/src/Apps/W1/ExpenseAgent/app/src/ExpenseReport/Tables/ExpenseReportLineVATSpec.Table.al index ef02d2f2d7c..a80ff9fcefe 100644 --- a/src/Apps/W1/ExpenseAgent/app/src/ExpenseReport/Tables/ExpenseReportLineVATSpec.Table.al +++ b/src/Apps/W1/ExpenseAgent/app/src/ExpenseReport/Tables/ExpenseReportLineVATSpec.Table.al @@ -185,9 +185,10 @@ table 6922 "Expense Report Line VAT Spec." begin if "Expense Category" = '' then exit; - if "Expense Subcategory" <> '' then - exit; if ExpenseCategory.Get("Expense Category") then begin + ExpenseCategory.TestField(Inactive, false); + if "Expense Subcategory" <> '' then + exit; "VAT Prod. Posting Group" := ExpenseCategory."VAT Prod. Posting Group"; "VAT %" := ExpenseCategory."Default VAT %"; Validate("VAT %"); @@ -207,6 +208,7 @@ table 6922 "Expense Report Line VAT Spec." begin if "Expense Subcategory" <> '' then begin if ExpenseSubcategory.Get("Expense Category", "Expense Subcategory") then begin + ExpenseSubcategory.TestField(Inactive, false); "VAT Prod. Posting Group" := ExpenseSubcategory."VAT Prod. Posting Group"; "VAT %" := ExpenseSubcategory."Default VAT %"; Validate("VAT %"); @@ -214,6 +216,7 @@ table 6922 "Expense Report Line VAT Spec." end else if "Expense Category" <> '' then if ExpenseCategory.Get("Expense Category") then begin + ExpenseCategory.TestField(Inactive, false); "VAT Prod. Posting Group" := ExpenseCategory."VAT Prod. Posting Group"; "VAT %" := ExpenseCategory."Default VAT %"; Validate("VAT %"); diff --git a/src/Apps/W1/ExpenseAgent/test/src/ExpensePostingVATTest.Codeunit.al b/src/Apps/W1/ExpenseAgent/test/src/ExpensePostingVATTest.Codeunit.al index 6b92aa79dd2..2afacafa73a 100644 --- a/src/Apps/W1/ExpenseAgent/test/src/ExpensePostingVATTest.Codeunit.al +++ b/src/Apps/W1/ExpenseAgent/test/src/ExpensePostingVATTest.Codeunit.al @@ -11,6 +11,7 @@ using Microsoft.Finance.VAT.Ledger; using Microsoft.Finance.VAT.Setup; using Microsoft.HumanResources.Employee; using Microsoft.Purchases.Vendor; +using System.Environment; using System.TestLibraries.Utilities; codeunit 148330 "Expense Posting VAT Test" @@ -30,10 +31,10 @@ codeunit 148330 "Expense Posting VAT Test" LibraryVariableStorage: Codeunit "Library - Variable Storage"; IsInitialized: Boolean; CanModifyLinesQst: Label 'You have modified %1 which will also update the lines.\\Do you want to continue?', Comment = '%1 = Field Caption'; - PostExpenseReportQst: Label 'Do you want to post Expense Report %1?', Comment = '%1 = Expense Report No.'; NotApprovedForVATReclaimCategoryErr: Label 'VAT Reclaim Status is not set for Line with Expense Category %1.', Comment = '%1 = Expense Category'; NotApprovedForVATReclaimErr: Label 'VAT Reclaim Status is not set for Line with Expense Category %1 and Expense Subcategory %2.', Comment = '%1 = Expense Category, %2 = Expense Subcategory'; ModifyOrDeleteAgentVATSpecErr: Label 'Modifications and delete are not allowed for records created by the Expense Agent API.'; + AgentVATSpecInsertNotAuthorizedErr: Label 'Agent-authored VAT specifications must be created through an authorized Expense Agent request.'; [Test] [HandlerFunctions('ExpensesModalPageHandler,ConfirmHandler')] @@ -547,6 +548,51 @@ codeunit 148330 "Expense Posting VAT Test" LibraryVariableStorage.AssertEmpty(); end; + [Test] + procedure InactiveExpenseCategoryCannotBeValidatedOnVATSpec() + var + ExpenseCategory: Record "Expense Category"; + ExpenseReportLineVATSpec: Record "Expense Report Line VAT Spec."; + begin + // [SCENARIO] An inactive expense category cannot be selected on a VAT specification. + Initialize(); + + // [GIVEN] An inactive expense category. + LibraryExpense.CreateExpenseCategory(ExpenseCategory, ExpenseCategory."Reimbursement Type"::"Employee Paid", ExpenseCategory."Expense Detail Required"::" "); + ExpenseCategory.Validate(Inactive, true); + ExpenseCategory.Modify(true); + + // [WHEN] The category is validated on a VAT specification. + asserterror ExpenseReportLineVATSpec.Validate("Expense Category", ExpenseCategory.Code); + + // [THEN] The inactive category is rejected immediately. + Assert.ExpectedTestFieldError(ExpenseCategory.FieldCaption(Inactive), Format(false)); + end; + + [Test] + procedure InactiveExpenseSubcategoryCannotBeValidatedOnVATSpec() + var + ExpenseCategory: Record "Expense Category"; + ExpenseReportLineVATSpec: Record "Expense Report Line VAT Spec."; + ExpenseSubcategory: Record "Expense Subcategory"; + begin + // [SCENARIO] An inactive expense subcategory cannot be selected on a VAT specification. + Initialize(); + + // [GIVEN] An active category with an inactive subcategory. + LibraryExpense.CreateExpenseCategory(ExpenseCategory, ExpenseCategory."Reimbursement Type"::"Employee Paid", ExpenseCategory."Expense Detail Required"::" "); + LibraryExpense.CreateExpenseSubCategory(ExpenseSubcategory, ExpenseCategory.Code, true); + ExpenseSubcategory.Validate(Inactive, true); + ExpenseSubcategory.Modify(true); + ExpenseReportLineVATSpec."Expense Category" := ExpenseCategory.Code; + + // [WHEN] The subcategory is validated on a VAT specification. + asserterror ExpenseReportLineVATSpec.Validate("Expense Subcategory", ExpenseSubcategory.Code); + + // [THEN] The inactive subcategory is rejected immediately. + Assert.ExpectedTestFieldError(ExpenseSubcategory.FieldCaption(Inactive), Format(false)); + end; + [Test] [HandlerFunctions('ExpensesModalPageHandler,ConfirmHandler')] procedure ModifiedVATSpecIsRecalculatedAndPostedInReimbursementCurrency() @@ -788,6 +834,44 @@ codeunit 148330 "Expense Posting VAT Test" Assert.AreNearlyEqual(20, ExpenseVATSpecification."VAT Amount", 0.01, 'The VAT amount must be calculated.'); end; + [Test] + procedure UpdateVATSpecificationPreservesSubcategoryForNonItemizedExpense() + var + Expense: Record Expense; + ExpenseCategory: Record "Expense Category"; + ExpenseSubCategory: array[2] of Record "Expense Subcategory"; + ExpenseUser: Record "Expense User"; + ExpenseVATSpecification: Record "Expense VAT Specification"; + VATPostingSetup: array[2] of Record "VAT Posting Setup"; + begin + // [SCENARIO] Updating the VAT specification for a non-itemized expense preserves its subcategory VAT defaults. + Initialize(); + + // [GIVEN] A non-itemized category with 10% VAT and a selected subcategory with 20% VAT. + LibraryExpense.CreateExpenseUser(ExpenseUser); + LibraryExpense.CreateExpenseCategory(ExpenseCategory, ExpenseCategory."Reimbursement Type"::"Employee Paid", ExpenseCategory."Expense Detail Required"::" "); + CreateSubcategoryWithVATRate(ExpenseSubCategory[1], ExpenseCategory.Code, 10, VATPostingSetup[1]); + CreateSubcategoryWithVATRate(ExpenseSubCategory[2], ExpenseCategory.Code, 20, VATPostingSetup[2]); + ExpenseCategory.Validate("VAT Prod. Posting Group", VATPostingSetup[1]."VAT Prod. Posting Group"); + ExpenseCategory.Validate("Default VAT %", VATPostingSetup[1]."VAT %"); + ExpenseCategory.Modify(true); + LibraryExpense.CreateExpense(Expense, ExpenseUser."No.", ExpenseCategory.Code, ExpenseSubCategory[2].Code, '', true, '', 120); + + // [WHEN] The VAT specification is updated. + Expense.UpdateVATSpecification(Expense."No."); + + // [THEN] The generated row retains the selected subcategory and uses its VAT defaults for the amounts. + ExpenseVATSpecification.SetRange("Expense No.", Expense."No."); + Assert.RecordCount(ExpenseVATSpecification, 1); + ExpenseVATSpecification.FindFirst(); + Assert.AreEqual(ExpenseCategory.Code, ExpenseVATSpecification."Expense Category", 'The expense category must be copied.'); + Assert.AreEqual(ExpenseSubCategory[2].Code, ExpenseVATSpecification."Expense Subcategory", 'The expense subcategory must be copied.'); + Assert.AreEqual(VATPostingSetup[2]."VAT Prod. Posting Group", ExpenseVATSpecification."VAT Prod. Posting Group", 'The subcategory VAT product posting group must be used.'); + Assert.AreNearlyEqual(20, ExpenseVATSpecification."VAT %", 0.01, 'The subcategory VAT percentage must be used.'); + Assert.AreNearlyEqual(100, ExpenseVATSpecification."VAT Base Amount", 0.01, 'The VAT base amount must use the subcategory VAT percentage.'); + Assert.AreNearlyEqual(20, ExpenseVATSpecification."VAT Amount", 0.01, 'The VAT amount must use the subcategory VAT percentage.'); + end; + [Test] procedure UpdateVATSpecificationCreatesSpecForZeroRatedNonItemizedExpense() var @@ -824,6 +908,44 @@ codeunit 148330 "Expense Posting VAT Test" Assert.AreEqual(0, ExpenseVATSpecification."VAT Amount", 'The zero-rated VAT amount must be zero.'); end; + [Test] + procedure AgentVATSpecificationAPIRejectsNonSaaSCaller() + var + ExpenseVATSpecification: Record "Expense VAT Specification"; + EnvironmentInfo: Codeunit "Environment Information"; + ExpenseVATSpecAPI: Page "Expense VAT Spec. API"; + begin + // [SCENARIO] The Expense VAT Specification API rejects callers outside SaaS. + if EnvironmentInfo.IsSaaSInfrastructure() then + exit; + + // [WHEN] A caller attempts to insert an agent-authored VAT specification through the API. + asserterror ExpenseVATSpecAPI.InsertVATSpecification(ExpenseVATSpecification); + + // [THEN] The caller is rejected before the row is inserted. + Assert.ExpectedError(AgentVATSpecInsertNotAuthorizedErr); + end; + + [Test] + procedure AgentVATSpecificationInsertRejectsNonSaaSCaller() + var + ExpenseVATSpecification: Record "Expense VAT Specification"; + EnvironmentInfo: Codeunit "Environment Information"; + begin + // [SCENARIO] The table authorization subscriber rejects agent-authored VAT specifications outside SaaS. + if EnvironmentInfo.IsSaaSInfrastructure() then + exit; + + // [GIVEN] An agent-authored VAT specification. + ExpenseVATSpecification.Source := ExpenseVATSpecification.Source::Agent; + + // [WHEN] A caller attempts to insert an agent-authored VAT specification without running table triggers. + asserterror ExpenseVATSpecification.Insert(false); + + // [THEN] The caller is rejected before the row is inserted. + Assert.ExpectedError(AgentVATSpecInsertNotAuthorizedErr); + end; + [Test] [TransactionModel(TransactionModel::AutoCommit)] procedure AgentVATSpecificationCannotBeUpdatedModifiedOrDeleted() @@ -849,11 +971,13 @@ codeunit 148330 "Expense Posting VAT Test" ExpenseVATSpecification.Init(); ExpenseVATSpecification."Expense No." := Expense."No."; ExpenseVATSpecification."Line No." := 1; - ExpenseVATSpecification.Source := ExpenseVATSpecification.Source::Agent; + ExpenseVATSpecification.Source := ExpenseVATSpecification.Source::Manual; ExpenseVATSpecification."VAT Bus. Posting Group" := VATPostingSetup."VAT Bus. Posting Group"; ExpenseVATSpecification."VAT Prod. Posting Group" := VATPostingSetup."VAT Prod. Posting Group"; ExpenseVATSpecification.Amount := 120; ExpenseVATSpecification.Insert(true); + ExpenseVATSpecification.Source := ExpenseVATSpecification.Source::Agent; + ExpenseVATSpecification.Modify(false); Commit(); // [WHEN] The VAT specification is regenerated, modified, or deleted. @@ -1150,7 +1274,7 @@ codeunit 148330 "Expense Posting VAT Test" local procedure PostExpenseReportWithConfirmation(var ExpenseReportPost: Codeunit "Expense Report-Post"; var ExpenseReportHeader: Record "Expense Report Header") begin - LibraryVariableStorage.Enqueue(StrSubstNo(PostExpenseReportQst, ExpenseReportHeader."No.")); + LibraryVariableStorage.Enqueue(ExpenseReportHeader."No."); LibraryVariableStorage.Enqueue(true); ExpenseReportPost.PostExpenseReport(ExpenseReportHeader); LibraryVariableStorage.AssertEmpty(); @@ -1443,7 +1567,7 @@ codeunit 148330 "Expense Posting VAT Test" [ConfirmHandler] procedure ConfirmHandler(Question: Text[1024]; var Reply: Boolean) begin - Assert.AreEqual(LibraryVariableStorage.DequeueText(), Question, 'The expense report posting confirmation is incorrect.'); + Assert.ExpectedConfirm(LibraryVariableStorage.DequeueText(), Question); Reply := LibraryVariableStorage.DequeueBoolean(); end; } From d81ea1fb0d3f66fe08330e53b9820aed906ba71a Mon Sep 17 00:00:00 2001 From: Alexander Yakunin Date: Fri, 21 Aug 2026 11:32:31 +0200 Subject: [PATCH 18/29] Improve upgrade --- .../UpgradeExpReportVATSpec.Codeunit.al | 39 +++++++++++++++++++ 1 file changed, 39 insertions(+) diff --git a/src/Apps/W1/ExpenseAgent/app/src/ExpenseReport/Codeunits/UpgradeExpReportVATSpec.Codeunit.al b/src/Apps/W1/ExpenseAgent/app/src/ExpenseReport/Codeunits/UpgradeExpReportVATSpec.Codeunit.al index bfc2144d27d..1ebd10c99a4 100644 --- a/src/Apps/W1/ExpenseAgent/app/src/ExpenseReport/Codeunits/UpgradeExpReportVATSpec.Codeunit.al +++ b/src/Apps/W1/ExpenseAgent/app/src/ExpenseReport/Codeunits/UpgradeExpReportVATSpec.Codeunit.al @@ -17,6 +17,7 @@ codeunit 7105 "Upgrade Exp. Report VAT Spec" Permissions = tabledata Currency = r, tabledata "Currency Exchange Rate" = r, tabledata "Expense Report Header" = r, + tabledata "Expense Report Line" = r, tabledata "Expense Report Line VAT Spec." = rm, tabledata "General Ledger Setup" = r, tabledata "Posted Expense Report Header" = r, @@ -27,6 +28,43 @@ codeunit 7105 "Upgrade Exp. Report VAT Spec" BackfillReimbursementAmounts(); end; + local procedure BackfillExpenseReportLineVATSpecCurrencyMetadata() + var + ExpenseReportLine: Record "Expense Report Line"; + ExpenseReportLineVATSpec: Record "Expense Report Line VAT Spec."; + CachedDocumentNo: Code[20]; + CachedDocumentLineNo: Integer; + HasCachedDocumentLine: Boolean; + ParentLineFound: Boolean; + begin + ExpenseReportLine.SetLoadFields("Expense Currency Code", "Expense Currency Factor"); + ExpenseReportLineVATSpec.SetCurrentKey("Document No.", "Document Line No.", "Line No."); + ExpenseReportLineVATSpec.SetLoadFields("Document No.", "Document Line No.", "Currency Code", "Currency Factor"); + CachedDocumentNo := ''; + CachedDocumentLineNo := 0; + if ExpenseReportLineVATSpec.FindSet(true) then + repeat + if (not HasCachedDocumentLine) or + (CachedDocumentNo <> ExpenseReportLineVATSpec."Document No.") or + (CachedDocumentLineNo <> ExpenseReportLineVATSpec."Document Line No.") + then begin + CachedDocumentNo := ExpenseReportLineVATSpec."Document No."; + CachedDocumentLineNo := ExpenseReportLineVATSpec."Document Line No."; + HasCachedDocumentLine := true; + ParentLineFound := ExpenseReportLine.Get(CachedDocumentNo, CachedDocumentLineNo); + end; + + if ParentLineFound then + if (ExpenseReportLineVATSpec."Currency Code" <> ExpenseReportLine."Expense Currency Code") or + (ExpenseReportLineVATSpec."Currency Factor" <> ExpenseReportLine."Expense Currency Factor") + then begin + ExpenseReportLineVATSpec."Currency Code" := ExpenseReportLine."Expense Currency Code"; + ExpenseReportLineVATSpec."Currency Factor" := ExpenseReportLine."Expense Currency Factor"; + ExpenseReportLineVATSpec.Modify(false); + end; + until ExpenseReportLineVATSpec.Next() = 0; + end; + local procedure BackfillReimbursementAmounts() var UpgradeTag: Codeunit "Upgrade Tag"; @@ -34,6 +72,7 @@ codeunit 7105 "Upgrade Exp. Report VAT Spec" if UpgradeTag.HasUpgradeTag(GetBackfillReimbursementAmountsUpgradeTag()) then exit; + BackfillExpenseReportLineVATSpecCurrencyMetadata(); CopyReimbursementAmountsFromLCY(); BackfillExpenseReportLineVATSpecs(); BackfillPostedExpenseReportLineVATSpecs(); From 2ac54a7855c8cd9cf50a573de5e8d72bc00c0efd Mon Sep 17 00:00:00 2001 From: Claudiu Ciumedean Date: Fri, 21 Aug 2026 16:46:40 +0200 Subject: [PATCH 19/29] Allow local agent VAT specification inserts Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../ExpenseAgentAPIValidation.Codeunit.al | 2 +- .../src/ExpensePostingVATTest.Codeunit.al | 25 +++++++++++-------- 2 files changed, 15 insertions(+), 12 deletions(-) diff --git a/src/Apps/W1/ExpenseAgent/app/src/Common/ExpenseAgentAPIValidation.Codeunit.al b/src/Apps/W1/ExpenseAgent/app/src/Common/ExpenseAgentAPIValidation.Codeunit.al index 7817015899d..adfc1c8e209 100644 --- a/src/Apps/W1/ExpenseAgent/app/src/Common/ExpenseAgentAPIValidation.Codeunit.al +++ b/src/Apps/W1/ExpenseAgent/app/src/Common/ExpenseAgentAPIValidation.Codeunit.al @@ -35,7 +35,7 @@ codeunit 6993 "Expense Agent API Validation" EnvironmentInfo: Codeunit "Environment Information"; begin if not EnvironmentInfo.IsSaaSInfrastructure() then - Error(AgentVATSpecInsertNotAuthorizedErr); + exit; if not IsCurrentUserExpenseAgent() then Error(AgentVATSpecInsertNotAuthorizedErr); diff --git a/src/Apps/W1/ExpenseAgent/test/src/ExpensePostingVATTest.Codeunit.al b/src/Apps/W1/ExpenseAgent/test/src/ExpensePostingVATTest.Codeunit.al index 2afacafa73a..9210b334394 100644 --- a/src/Apps/W1/ExpenseAgent/test/src/ExpensePostingVATTest.Codeunit.al +++ b/src/Apps/W1/ExpenseAgent/test/src/ExpensePostingVATTest.Codeunit.al @@ -34,7 +34,6 @@ codeunit 148330 "Expense Posting VAT Test" NotApprovedForVATReclaimCategoryErr: Label 'VAT Reclaim Status is not set for Line with Expense Category %1.', Comment = '%1 = Expense Category'; NotApprovedForVATReclaimErr: Label 'VAT Reclaim Status is not set for Line with Expense Category %1 and Expense Subcategory %2.', Comment = '%1 = Expense Category, %2 = Expense Subcategory'; ModifyOrDeleteAgentVATSpecErr: Label 'Modifications and delete are not allowed for records created by the Expense Agent API.'; - AgentVATSpecInsertNotAuthorizedErr: Label 'Agent-authored VAT specifications must be created through an authorized Expense Agent request.'; [Test] [HandlerFunctions('ExpensesModalPageHandler,ConfirmHandler')] @@ -909,30 +908,34 @@ codeunit 148330 "Expense Posting VAT Test" end; [Test] - procedure AgentVATSpecificationAPIRejectsNonSaaSCaller() + procedure AgentVATSpecificationAPIAcceptsNonSaaSCaller() var ExpenseVATSpecification: Record "Expense VAT Specification"; EnvironmentInfo: Codeunit "Environment Information"; ExpenseVATSpecAPI: Page "Expense VAT Spec. API"; begin - // [SCENARIO] The Expense VAT Specification API rejects callers outside SaaS. + // [SCENARIO] The Expense VAT Specification API accepts callers outside SaaS for local agent development. if EnvironmentInfo.IsSaaSInfrastructure() then exit; + // [GIVEN] A VAT specification with a business posting group. + ExpenseVATSpecification."VAT Bus. Posting Group" := 'LOCAL'; + // [WHEN] A caller attempts to insert an agent-authored VAT specification through the API. - asserterror ExpenseVATSpecAPI.InsertVATSpecification(ExpenseVATSpecification); + ExpenseVATSpecAPI.InsertVATSpecification(ExpenseVATSpecification); - // [THEN] The caller is rejected before the row is inserted. - Assert.ExpectedError(AgentVATSpecInsertNotAuthorizedErr); + // [THEN] The agent-authored VAT specification is inserted. + Assert.AreEqual(ExpenseVATSpecification.Source::Agent, ExpenseVATSpecification.Source, 'The VAT specification source must be Agent.'); + Assert.IsTrue(ExpenseVATSpecification.Get(ExpenseVATSpecification."Expense No.", ExpenseVATSpecification."Line No."), 'The VAT specification must be inserted.'); end; [Test] - procedure AgentVATSpecificationInsertRejectsNonSaaSCaller() + procedure AgentVATSpecificationInsertAcceptsNonSaaSCaller() var ExpenseVATSpecification: Record "Expense VAT Specification"; EnvironmentInfo: Codeunit "Environment Information"; begin - // [SCENARIO] The table authorization subscriber rejects agent-authored VAT specifications outside SaaS. + // [SCENARIO] The table authorization subscriber accepts agent-authored VAT specifications outside SaaS. if EnvironmentInfo.IsSaaSInfrastructure() then exit; @@ -940,10 +943,10 @@ codeunit 148330 "Expense Posting VAT Test" ExpenseVATSpecification.Source := ExpenseVATSpecification.Source::Agent; // [WHEN] A caller attempts to insert an agent-authored VAT specification without running table triggers. - asserterror ExpenseVATSpecification.Insert(false); + ExpenseVATSpecification.Insert(false); - // [THEN] The caller is rejected before the row is inserted. - Assert.ExpectedError(AgentVATSpecInsertNotAuthorizedErr); + // [THEN] The agent-authored VAT specification is inserted. + Assert.IsTrue(ExpenseVATSpecification.Get(ExpenseVATSpecification."Expense No.", ExpenseVATSpecification."Line No."), 'The VAT specification must be inserted.'); end; [Test] From 0b80dda56431b26c81869c3498df61b3bcb6147b Mon Sep 17 00:00:00 2001 From: Alexander Yakunin Date: Mon, 24 Aug 2026 18:34:57 +0200 Subject: [PATCH 20/29] Update for code review --- .../app/src/APIs/ExpenseVATSpecAPI.Page.al | 11 --- .../ExpenseAgentAPIValidation.Codeunit.al | 5 - .../Tables/ExpenseReportHeader.Table.al | 4 + .../Tables/ExpenseReportLineVATSpec.Table.al | 4 +- .../Tables/PostedExpRepLineVATSpec.Table.al | 1 + .../src/API/ExpenseVATSpecAPITest.Codeunit.al | 92 +++++++++++++++++++ 6 files changed, 100 insertions(+), 17 deletions(-) create mode 100644 src/Apps/W1/ExpenseAgent/test/src/API/ExpenseVATSpecAPITest.Codeunit.al diff --git a/src/Apps/W1/ExpenseAgent/app/src/APIs/ExpenseVATSpecAPI.Page.al b/src/Apps/W1/ExpenseAgent/app/src/APIs/ExpenseVATSpecAPI.Page.al index 40d5202103a..161dd38a397 100644 --- a/src/Apps/W1/ExpenseAgent/app/src/APIs/ExpenseVATSpecAPI.Page.al +++ b/src/Apps/W1/ExpenseAgent/app/src/APIs/ExpenseVATSpecAPI.Page.al @@ -4,8 +4,6 @@ // ------------------------------------------------------------------------------------------------ namespace Microsoft.ExpenseAgent; -using System.Telemetry; - page 7085 "Expense VAT Spec. API" { APIGroup = 'expense'; @@ -123,18 +121,9 @@ page 7085 "Expense VAT Spec. API" end; internal procedure InsertVATSpecification(var ExpenseVATSpecification: Record "Expense VAT Specification") - var - ExpenseAgentSetup: Record "Expense Agent Setup"; - FeatureTelemetry: Codeunit "Feature Telemetry"; - TelemetryDimensions: Dictionary of [Text, Text]; begin ExpenseVATSpecification.Source := ExpenseVATSpecification.Source::Agent; ExpenseVATSpecification.Insert(true); - - TelemetryDimensions.Add('HasExpenseCategory', Format(ExpenseVATSpecification."Expense Category" <> '')); - TelemetryDimensions.Add('HasExpenseSubcategory', Format(ExpenseVATSpecification."Expense Subcategory" <> '')); - TelemetryDimensions.Add('HasVATProductPostingGroup', Format(ExpenseVATSpecification."VAT Prod. Posting Group" <> '')); - FeatureTelemetry.LogUptake('0000UZ7', ExpenseAgentSetup.GetFeatureName(), Enum::"Feature Uptake Status"::Used, TelemetryDimensions); end; local procedure VerifyExpenseAgentCaller() diff --git a/src/Apps/W1/ExpenseAgent/app/src/Common/ExpenseAgentAPIValidation.Codeunit.al b/src/Apps/W1/ExpenseAgent/app/src/Common/ExpenseAgentAPIValidation.Codeunit.al index adfc1c8e209..e0d0507e996 100644 --- a/src/Apps/W1/ExpenseAgent/app/src/Common/ExpenseAgentAPIValidation.Codeunit.al +++ b/src/Apps/W1/ExpenseAgent/app/src/Common/ExpenseAgentAPIValidation.Codeunit.al @@ -31,12 +31,7 @@ codeunit 6993 "Expense Agent API Validation" end; internal procedure VerifyAgentVATSpecificationAccess() - var - EnvironmentInfo: Codeunit "Environment Information"; begin - if not EnvironmentInfo.IsSaaSInfrastructure() then - exit; - if not IsCurrentUserExpenseAgent() then Error(AgentVATSpecInsertNotAuthorizedErr); end; 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 3c94f3f5809..f0a0be14bc1 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 @@ -697,6 +697,10 @@ table 6906 "Expense Report Header" ExpenseReportLineVATSpec: Record "Expense Report Line VAT Spec."; begin ExpenseReportLineVATSpec.SetRange("Document No.", "No."); + ExpenseReportLineVATSpec.SetLoadFields( + "Currency Code", "VAT Base Amount (LCY)", "VAT Amount", "VAT Amount (LCY)", "Amount (LCY)", "Reclaim %", + "VAT Base Amount (RCY)", "VAT Amount (RCY)", "Amount (RCY)", "Reclaim VAT Amount", + "Reclaim VAT Amount (LCY)", "Reclaim VAT Amount (RCY)"); if ExpenseReportLineVATSpec.FindSet(true) then repeat ExpenseReportLineVATSpec.UpdateReimbursementAmounts(Rec); diff --git a/src/Apps/W1/ExpenseAgent/app/src/ExpenseReport/Tables/ExpenseReportLineVATSpec.Table.al b/src/Apps/W1/ExpenseAgent/app/src/ExpenseReport/Tables/ExpenseReportLineVATSpec.Table.al index a80ff9fcefe..c3aadb3b803 100644 --- a/src/Apps/W1/ExpenseAgent/app/src/ExpenseReport/Tables/ExpenseReportLineVATSpec.Table.al +++ b/src/Apps/W1/ExpenseAgent/app/src/ExpenseReport/Tables/ExpenseReportLineVATSpec.Table.al @@ -433,8 +433,10 @@ table 6922 "Expense Report Line VAT Spec." local procedure GetExpenseReportHeader() begin - if "Document No." <> ExpenseReportHeader."No." then + if "Document No." <> ExpenseReportHeader."No." then begin + ExpenseReportHeader.SetLoadFields("Reimbursement Currency Code"); ExpenseReportHeader.Get("Document No."); + end; end; local procedure GetExpenseReportLine() diff --git a/src/Apps/W1/ExpenseAgent/app/src/ExpenseReport/Tables/PostedExpRepLineVATSpec.Table.al b/src/Apps/W1/ExpenseAgent/app/src/ExpenseReport/Tables/PostedExpRepLineVATSpec.Table.al index 55105475af0..016fa474b51 100644 --- a/src/Apps/W1/ExpenseAgent/app/src/ExpenseReport/Tables/PostedExpRepLineVATSpec.Table.al +++ b/src/Apps/W1/ExpenseAgent/app/src/ExpenseReport/Tables/PostedExpRepLineVATSpec.Table.al @@ -288,6 +288,7 @@ table 6934 "Posted Exp. Rep. Line VAT Spec" var PostedExpenseReportHeader: Record "Posted Expense Report Header"; begin + PostedExpenseReportHeader.SetLoadFields("Reimbursement Currency Code"); if PostedExpenseReportHeader.Get("Expense Report No.") then exit(PostedExpenseReportHeader."Reimbursement Currency Code"); end; diff --git a/src/Apps/W1/ExpenseAgent/test/src/API/ExpenseVATSpecAPITest.Codeunit.al b/src/Apps/W1/ExpenseAgent/test/src/API/ExpenseVATSpecAPITest.Codeunit.al new file mode 100644 index 00000000000..d2fb06bdfa5 --- /dev/null +++ b/src/Apps/W1/ExpenseAgent/test/src/API/ExpenseVATSpecAPITest.Codeunit.al @@ -0,0 +1,92 @@ +// ------------------------------------------------------------------------------------------------ +// 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 148348 "Expense VAT Spec. API Test" +{ + Subtype = Test; + TestType = IntegrationTest; + 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 'expenseVATSpecifications', Locked = true; + + [Test] + procedure AgentVATSpecificationIsInsertedThroughAPI() + var + Expense: Record Expense; + ExpenseCategory: Record "Expense Category"; + ExpenseUser: Record "Expense User"; + ExpenseVATSpecification: Record "Expense VAT Specification"; + RequestBody: JsonObject; + ResponseText: Text; + RequestText: Text; + TargetURL: Text; + begin + // [SCENARIO] An authorized Expense Agent caller creates a VAT specification through the OData API. + Initialize(); + + // [GIVEN] A persisted expense that can own the agent-authored VAT specification. + LibraryExpense.CreateExpenseUser(ExpenseUser); + LibraryExpense.CreateExpenseCategory( + ExpenseCategory, ExpenseCategory."Reimbursement Type"::"Employee Paid", ExpenseCategory."Expense Detail Required"::" "); + LibraryExpense.CreateExpense(Expense, ExpenseUser."No.", ExpenseCategory.Code, '', '', true, '', 120); + Commit(); + + // [WHEN] The VAT specification is posted through the published API endpoint. + RequestBody.Add('expenseNo', Expense."No."); + RequestBody.Add('vatPercent', 20); + RequestBody.Add('vatBaseAmount', 100); + RequestBody.Add('vatAmount', 20); + RequestBody.Add('amount', 120); + RequestBody.Add('amountLCY', 120); + RequestBody.Add('vatBaseAmountLCY', 100); + RequestBody.Add('vatAmountLCY', 20); + RequestBody.Add('confidence', 0.95); + RequestBody.WriteTo(RequestText); + TargetURL := LibraryGraphMgt.CreateTargetURL('', Page::"Expense VAT Spec. API", ServiceNameTok); + LibraryGraphMgt.PostToWebServiceAndCheckResponseCode(TargetURL, RequestText, ResponseText, 201); + + // [THEN] OnInsertRecord inserted one row and exit(false) prevented a duplicate platform insert. + ExpenseVATSpecification.SetRange("Expense No.", Expense."No."); + Assert.RecordCount(ExpenseVATSpecification, 1); + ExpenseVATSpecification.FindFirst(); + + // [THEN] The endpoint persisted the caller payload as an immutable agent-authored source. + Assert.AreEqual(ExpenseVATSpecification.Source::Agent, ExpenseVATSpecification.Source, 'The persisted source must be Agent.'); + Assert.AreEqual(20, ExpenseVATSpecification."VAT %", 'The VAT percent must match the API payload.'); + Assert.AreEqual(100, ExpenseVATSpecification."VAT Base Amount", 'The VAT base amount must match the API payload.'); + Assert.AreEqual(20, ExpenseVATSpecification."VAT Amount", 'The VAT amount must match the API payload.'); + Assert.AreEqual(120, ExpenseVATSpecification.Amount, 'The amount must match the API payload.'); + Assert.AreEqual(120, ExpenseVATSpecification."Amount (LCY)", 'The LCY amount must match the API payload.'); + Assert.AreEqual(0.95, ExpenseVATSpecification.Confidence, 'The confidence must match the API payload.'); + Assert.AreNotEqual( + 0, + StrPos(LowerCase(ResponseText), LowerCase(LibraryGraphMgt.StripBrackets(Format(ExpenseVATSpecification.SystemId)))), + 'The response must identify the row inserted by OnInsertRecord.'); + end; + + local procedure Initialize() + begin + LibraryTestInitialize.OnTestInitialize(Codeunit::"Expense VAT Spec. API Test"); + if IsInitialized then + exit; + + BindSubscription(APITestAuthHelper); + LibraryTestInitialize.OnBeforeTestSuiteInitialize(Codeunit::"Expense VAT Spec. API Test"); + LibraryExpense.SetupNumberSeriesInExpenseMgmt(); + IsInitialized := true; + Commit(); + LibraryTestInitialize.OnAfterTestSuiteInitialize(Codeunit::"Expense VAT Spec. API Test"); + end; +} \ No newline at end of file From 6362152a60bd7cb9e7f78e10022d9e990e9b7aac Mon Sep 17 00:00:00 2001 From: Alexander Yakunin Date: Tue, 25 Aug 2026 09:15:09 +0200 Subject: [PATCH 21/29] Temporarily disable new API test --- .../Expense_Agent_Tests.DisabledTest.json | 5 +++++ 1 file changed, 5 insertions(+) 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 44be58a7731..27cd512e9a1 100644 --- a/src/DisabledTests/Expense_Agent_Tests/Expense_Agent_Tests.DisabledTest.json +++ b/src/DisabledTests/Expense_Agent_Tests/Expense_Agent_Tests.DisabledTest.json @@ -203,5 +203,10 @@ "codeunitId": 148319, "codeunitName": "Cancel Posted Exp. Report Test", "method": "CancelMultiLineReportGeneratesNewTransactionNoForReversedLines" + }, + { + "codeunitId": 148348, + "codeunitName": "Expense VAT Spec. API Test", + "method": "AgentVATSpecificationIsInsertedThroughAPI" } ] From cca9f21fb377c63678d4242cc7bb7cf00ef1fa04 Mon Sep 17 00:00:00 2001 From: Alexander Yakunin Date: Tue, 25 Aug 2026 12:57:58 +0200 Subject: [PATCH 22/29] Update for code review comments --- .../app/src/APIs/ExpenseVATSpecAPI.Page.al | 13 --------- .../ExpenseAgentAPIValidation.Codeunit.al | 16 ----------- .../Tables/ExpenseVATSpecification.Table.al | 1 + .../UpgradeExpReportVATSpec.Codeunit.al | 19 +++++++++++++ .../Tables/ExpenseReportHeader.Table.al | 28 +++++++++---------- 5 files changed, 34 insertions(+), 43 deletions(-) diff --git a/src/Apps/W1/ExpenseAgent/app/src/APIs/ExpenseVATSpecAPI.Page.al b/src/Apps/W1/ExpenseAgent/app/src/APIs/ExpenseVATSpecAPI.Page.al index 161dd38a397..cd694743783 100644 --- a/src/Apps/W1/ExpenseAgent/app/src/APIs/ExpenseVATSpecAPI.Page.al +++ b/src/Apps/W1/ExpenseAgent/app/src/APIs/ExpenseVATSpecAPI.Page.al @@ -108,12 +108,6 @@ page 7085 "Expense VAT Spec. API" ExpenseAgentAPIValidation.VerifyAgentAccess(); end; - trigger OnNewRecord(BelowxRec: Boolean) - begin - VerifyExpenseAgentCaller(); - Rec.Source := Rec.Source::Agent; - end; - trigger OnInsertRecord(BelowxRec: Boolean): Boolean begin InsertVATSpecification(Rec); @@ -125,11 +119,4 @@ page 7085 "Expense VAT Spec. API" ExpenseVATSpecification.Source := ExpenseVATSpecification.Source::Agent; ExpenseVATSpecification.Insert(true); end; - - local procedure VerifyExpenseAgentCaller() - var - ExpenseAgentAPIValidation: Codeunit "Expense Agent API Validation"; - begin - ExpenseAgentAPIValidation.VerifyAgentVATSpecificationAccess(); - end; } diff --git a/src/Apps/W1/ExpenseAgent/app/src/Common/ExpenseAgentAPIValidation.Codeunit.al b/src/Apps/W1/ExpenseAgent/app/src/Common/ExpenseAgentAPIValidation.Codeunit.al index e0d0507e996..c301821b843 100644 --- a/src/Apps/W1/ExpenseAgent/app/src/Common/ExpenseAgentAPIValidation.Codeunit.al +++ b/src/Apps/W1/ExpenseAgent/app/src/Common/ExpenseAgentAPIValidation.Codeunit.al @@ -18,24 +18,8 @@ codeunit 6993 "Expense Agent API Validation" var AgentNotEnabledErr: Label 'Expense Agent is not enabled. Please contact your administrator.'; CapabilityNotEnabledErr: Label 'The "%1" capability is not enabled. Please contact your administrator to enable the capability.', Comment = '%1 = a capability name, such as Expense Agent'; - AgentVATSpecInsertNotAuthorizedErr: Label 'Agent-authored VAT specifications must be created through an authorized Expense Agent request.'; ExpenseAgentAadAppIdTxt: Label 'ee1eb5fd-719b-44f2-97d0-0efd34bc4148', Locked = true; - [EventSubscriber(ObjectType::Table, Database::"Expense VAT Specification", OnBeforeInsertEvent, '', false, false)] - local procedure CheckAgentVATSpecificationInsert(var Rec: Record "Expense VAT Specification"; RunTrigger: Boolean) - begin - if Rec.IsTemporary() or (Rec.Source <> Rec.Source::Agent) then - exit; - - VerifyAgentVATSpecificationAccess(); - end; - - internal procedure VerifyAgentVATSpecificationAccess() - begin - if not IsCurrentUserExpenseAgent() then - Error(AgentVATSpecInsertNotAuthorizedErr); - end; - procedure VerifyAgentAccess() begin VerifyAgentAccess(false); diff --git a/src/Apps/W1/ExpenseAgent/app/src/Expense/Tables/ExpenseVATSpecification.Table.al b/src/Apps/W1/ExpenseAgent/app/src/Expense/Tables/ExpenseVATSpecification.Table.al index 6157d275f2f..94f00c249bd 100644 --- a/src/Apps/W1/ExpenseAgent/app/src/Expense/Tables/ExpenseVATSpecification.Table.al +++ b/src/Apps/W1/ExpenseAgent/app/src/Expense/Tables/ExpenseVATSpecification.Table.al @@ -164,6 +164,7 @@ table 6918 "Expense VAT Specification" end else begin TestField("Amount (LCY)"); TestField(Amount); + Expense."Currency Factor" := Amount / "Amount (LCY)"; end; Validate("VAT %"); end; diff --git a/src/Apps/W1/ExpenseAgent/app/src/ExpenseReport/Codeunits/UpgradeExpReportVATSpec.Codeunit.al b/src/Apps/W1/ExpenseAgent/app/src/ExpenseReport/Codeunits/UpgradeExpReportVATSpec.Codeunit.al index 1ebd10c99a4..4f409f55f48 100644 --- a/src/Apps/W1/ExpenseAgent/app/src/ExpenseReport/Codeunits/UpgradeExpReportVATSpec.Codeunit.al +++ b/src/Apps/W1/ExpenseAgent/app/src/ExpenseReport/Codeunits/UpgradeExpReportVATSpec.Codeunit.al @@ -23,6 +23,9 @@ codeunit 7105 "Upgrade Exp. Report VAT Spec" tabledata "Posted Expense Report Header" = r, tabledata "Posted Exp. Rep. Line VAT Spec" = rm; + var + BackfillCompletedTelemetryMsg: Label 'Expense VAT specification reimbursement amount backfill completed.', Locked = true; + trigger OnUpgradePerCompany() begin BackfillReimbursementAmounts(); @@ -77,9 +80,25 @@ codeunit 7105 "Upgrade Exp. Report VAT Spec" BackfillExpenseReportLineVATSpecs(); BackfillPostedExpenseReportLineVATSpecs(); + LogBackfillCompleted(); SetBackfillReimbursementAmountsUpgradeTag(); end; + local procedure LogBackfillCompleted() + var + ExpenseReportLineVATSpec: Record "Expense Report Line VAT Spec."; + PostedExpenseReportLineVATSpec: Record "Posted Exp. Rep. Line VAT Spec"; + ExpenseAuditSubscribers: Codeunit "Expense Audit Subscribers"; + TelemetryDimensions: Dictionary of [Text, Text]; + begin + TelemetryDimensions.Add('Category', ExpenseAuditSubscribers.TelemetryCategory()); + TelemetryDimensions.Add('ExpenseReportVATSpecificationCount', Format(ExpenseReportLineVATSpec.Count())); + TelemetryDimensions.Add('PostedExpenseReportVATSpecificationCount', Format(PostedExpenseReportLineVATSpec.Count())); + Session.LogMessage( + '0000V16', BackfillCompletedTelemetryMsg, Verbosity::Normal, DataClassification::SystemMetadata, TelemetryScope::ExtensionPublisher, + TelemetryDimensions); + end; + internal procedure SetBackfillReimbursementAmountsUpgradeTag() var UpgradeTag: Codeunit "Upgrade Tag"; 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 9f1a13f98ed..b7d93d6f5af 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 @@ -103,7 +103,7 @@ table 6906 "Expense Report Header" UpdateCurrencyFactor(); if xRec."Posting Date" <> Rec."Posting Date" then - UpdateReportLines(Rec.FieldName("Posting Date")); + UpdateReportLines(Rec.FieldCaption("Posting Date")); end; } field(6; "Description"; Text[100]) @@ -357,7 +357,7 @@ table 6906 "Expense Report Header" begin TestStatusOpen(); if xRec."VAT Bus. Posting Group" <> Rec."VAT Bus. Posting Group" then - UpdateReportLines(Rec.FieldName("VAT Bus. Posting Group")); + UpdateReportLines(Rec.FieldCaption("VAT Bus. Posting Group")); end; } field(42; "Submission DateTime"; DateTime) @@ -438,7 +438,7 @@ table 6906 "Expense Report Header" begin if "Reimbursement Currency Factor" <> xRec."Reimbursement Currency Factor" then begin TestStatusOpen(); - UpdateReportLines(Rec.FieldName("Reimbursement Currency Factor")); + UpdateReportLines(Rec.FieldCaption("Reimbursement Currency Factor")); end; end; } @@ -527,7 +527,7 @@ table 6906 "Expense Report Header" Rec."Spend Request Close" := false; if xRec."Spend Request No." <> Rec."Spend Request No." then - UpdateReportLines(Rec.FieldName("Spend Request No.")); + UpdateReportLines(Rec.FieldCaption("Spend Request No.")); end; } field(101; "Spend Request Close"; Boolean) @@ -660,7 +660,7 @@ table 6906 "Expense Report Header" end; end; - local procedure UpdateReportLines(CalledFromFieldName: Text) + local procedure UpdateReportLines(CalledFromFieldCaption: Text) var ExpenseReportLine: Record "Expense Report Line"; ConfirmManagement: Codeunit "Confirm Management"; @@ -668,7 +668,7 @@ table 6906 "Expense Report Header" if not ExpenseLinesExist() then exit; - if not ConfirmManagement.GetResponseOrDefault(StrSubstNo(CanModifyLinesQst, CalledFromFieldName), true) then + if not ConfirmManagement.GetResponseOrDefault(StrSubstNo(CanModifyLinesQst, CalledFromFieldCaption), true) then Error(''); ExpenseReportLine.SetRange("Document No.", "No."); @@ -676,19 +676,19 @@ table 6906 "Expense Report Header" repeat ExpenseReportLine.Initialize(Rec); - case CalledFromFieldName of - Rec.FieldName("Reimbursement Currency Code"), Rec.FieldName("Reimbursement Currency Factor"): + case CalledFromFieldCaption of + Rec.FieldCaption("Reimbursement Currency Code"), Rec.FieldCaption("Reimbursement Currency Factor"): UpdateCurrFactorOnReportLine(ExpenseReportLine); - Rec.FieldName("VAT Bus. Posting Group"): + Rec.FieldCaption("VAT Bus. Posting Group"): UpdateVATBusPostingGroupOnReportLine(ExpenseReportLine); - Rec.FieldName("Posting Date"): + Rec.FieldCaption("Posting Date"): UpdatePostingDateOnReportLine(ExpenseReportLine); - Rec.FieldName("Spend Request No."): + Rec.FieldCaption("Spend Request No."): UpdateSpendRequestOnReportLine(ExpenseReportLine); end; until ExpenseReportLine.Next() = 0; - if CalledFromFieldName in [Rec.FieldName("Reimbursement Currency Code"), Rec.FieldName("Reimbursement Currency Factor"), Rec.FieldName("Posting Date")] then + if CalledFromFieldCaption in [Rec.FieldCaption("Reimbursement Currency Code"), Rec.FieldCaption("Reimbursement Currency Factor"), Rec.FieldCaption("Posting Date")] then UpdateVATSpecReimbursementAmounts(); end; @@ -1101,13 +1101,13 @@ table 6906 "Expense Report Header" if UpdateCurrencyExchangeRates.ExchangeRatesForCurrencyExist(CurrencyDate, Rec."Reimbursement Currency Code") then begin Rec."Reimbursement Currency Factor" := CurrExchRate.ExchangeRate(CurrencyDate, Rec."Reimbursement Currency Code"); if (Rec."Reimbursement Currency Code" <> xRec."Reimbursement Currency Code") and (xRec."No." <> '') then - UpdateReportLines(Rec.FieldName("Reimbursement Currency Code")); + UpdateReportLines(Rec.FieldCaption("Reimbursement Currency Code")); end else UpdateCurrencyExchangeRates.ShowMissingExchangeRatesNotification("Reimbursement Currency Code"); end else begin Rec."Reimbursement Currency Factor" := 0; if "Reimbursement Currency Code" <> xRec."Reimbursement Currency Code" then - UpdateReportLines(Rec.FieldName("Reimbursement Currency Code")); + UpdateReportLines(Rec.FieldCaption("Reimbursement Currency Code")); end; end; From c30f9aca3f8014593dc0b3efc70b16f213cb0c21 Mon Sep 17 00:00:00 2001 From: Alexander Yakunin Date: Tue, 25 Aug 2026 15:08:03 +0200 Subject: [PATCH 23/29] Eliminate dependency on enable Non Ded VAT feature --- .../Codeunits/ExpenseReportPost.Codeunit.al | 13 ++++++++++++- .../Codeunits/CreateExpenseVATRates.Codeunit.al | 8 +------- .../src/Setup/Tables/ExpenseAgentSetup.Table.al | 16 ++++++++-------- .../test/src/ExpensePostingVATTest.Codeunit.al | 3 --- 4 files changed, 21 insertions(+), 19 deletions(-) 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 1e1f0ea5937..c2cc56c70c1 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 @@ -65,6 +65,7 @@ codeunit 6987 "Expense Report-Post" GenJnlPostLine: Codeunit "Gen. Jnl.-Post Line"; FeatureTelemetry: Codeunit "Feature Telemetry"; PreviewMode: Boolean; + DisableNonDeductibleVATAfterPost: Boolean; AmountToEmployee: Decimal; AmountToEmployeeLCY: Decimal; CanPostExpenseReportQst: Label 'Do you want to post Expense Report %1?', Comment = '%1 = Expense Report No.'; @@ -108,6 +109,12 @@ codeunit 6987 "Expense Report-Post" if not PreviewMode then begin if TrySendReimbursementNotification(PostedExpenseReportHeader) then; ExpenseReportHeader.Delete(true); + if DisableNonDeductibleVATAfterPost then begin + VATSetup.Get(); + VATSetup."Enable Non-Deductible VAT" := false; + VATSetup.Modify(); + DisableNonDeductibleVATAfterPost := false; + end; LogAgentVATSpecificationUsage(AgentVATSpecificationCount); end; @@ -902,7 +909,11 @@ codeunit 6987 "Expense Report-Post" exit; VATSetup.Get(); - VATSetup.TestField("Enable Non-Deductible VAT"); + if not VATSetup."Enable Non-Deductible VAT" then begin + VATSetup."Enable Non-Deductible VAT" := true; + VATSetup.Modify(); + DisableNonDeductibleVATAfterPost := true; + end; GenJournalLine.Validate("Non-Deductible VAT %", 100 - ExpenseReportLineVATSpec."Reclaim %"); GenJournalLine.Validate("Non-Deductible VAT Base", ExpenseReportLineVATSpec."VAT Base Amount (RCY)" * (100 - ExpenseReportLineVATSpec."Reclaim %") / 100); GenJournalLine.Validate("Non-Deductible VAT Amount", ExpenseReportLineVATSpec."VAT Amount (RCY)" - ExpenseReportLineVATSpec."Reclaim VAT Amount (RCY)"); diff --git a/src/Apps/W1/ExpenseAgent/app/src/Setup/Codeunits/CreateExpenseVATRates.Codeunit.al b/src/Apps/W1/ExpenseAgent/app/src/Setup/Codeunits/CreateExpenseVATRates.Codeunit.al index ab86217346f..f5fad4c1aeb 100644 --- a/src/Apps/W1/ExpenseAgent/app/src/Setup/Codeunits/CreateExpenseVATRates.Codeunit.al +++ b/src/Apps/W1/ExpenseAgent/app/src/Setup/Codeunits/CreateExpenseVATRates.Codeunit.al @@ -38,12 +38,6 @@ codeunit 6975 "Create Expense VAT Rates" CompanyInfo: Record "Company Information"; GLAccount: Record "G/L Account"; begin - ExpenseAgentSetup.GetRecordOnce(); - if ExpenseAgentSetup."Default VAT Bus. Posting Group" = '' then begin - ExpenseAgentSetup."Default VAT Bus. Posting Group" := XDOMESTICTxt; - ExpenseAgentSetup.Modify(); - end; - if not GLAccount.Get(XEXPENSEVATTok) then begin GLAccount.Init(); GLAccount."No." := XEXPENSEVATTok; @@ -140,6 +134,7 @@ codeunit 6975 "Create Expense VAT Rates" VATProductPostingGroup.Insert(); end; + ExpenseAgentSetup.GetRecordOnce(); if not VATPostingSetup.Get(ExpenseAgentSetup."Default VAT Bus. Posting Group", VATProdPostingGroup) then begin VATPostingSetup.Init(); VATPostingSetup.Validate("VAT Bus. Posting Group", ExpenseAgentSetup."Default VAT Bus. Posting Group"); @@ -170,7 +165,6 @@ codeunit 6975 "Create Expense VAT Rates" CreateExpenseCategories: Codeunit "Create Expense Categories"; XEXPENSEVATTok: Label 'EXPENSE VAT', Locked = true; // Virtual G/L account used for VAT on expenses - XDOMESTICTxt: Label 'DOMESTIC'; // DOMESTIC VAT Business Posting Group used as default for all rates created by this codeunit local procedure CreateVATRatesAT() begin diff --git a/src/Apps/W1/ExpenseAgent/app/src/Setup/Tables/ExpenseAgentSetup.Table.al b/src/Apps/W1/ExpenseAgent/app/src/Setup/Tables/ExpenseAgentSetup.Table.al index 86c1e550e13..d8324f5b9f1 100644 --- a/src/Apps/W1/ExpenseAgent/app/src/Setup/Tables/ExpenseAgentSetup.Table.al +++ b/src/Apps/W1/ExpenseAgent/app/src/Setup/Tables/ExpenseAgentSetup.Table.al @@ -664,14 +664,9 @@ table 6930 "Expense Agent Setup" ToolTip = 'Specifies whether VAT reclaim is allowed on expenses.'; trigger OnValidate() - var - VATSetup: Record "VAT Setup"; begin - if "Allow VAT Reclaim" then begin + if "Allow VAT Reclaim" then TestField("Default VAT Bus. Posting Group"); - VATSetup.Get(); - VATSetup.TestField("Enable Non-Deductible VAT"); - end; end; } field(105; "VAT Rates Applied"; Boolean) @@ -797,7 +792,7 @@ table 6930 "Expense Agent Setup" CreateEmployeesForExpenseUsersQst: Label 'Turning on this setting will enable automatic creation of records in the Employee table. This may impact your HR setup in Business Central.\\Are you sure you want to enable this feature?'; UpdateDefaultsApproverQst: Label 'You have changed the default approver.\\Do you also want to change approver from %1 to %2 for all expense users who currently have %1 as approver?', Comment = '%1 and %2 are both person names.'; UpdatingDefaultApproversLbl: Label 'Updating approvers...'; - + XDOMESTICTxt: Label 'DOMESTIC'; // DOMESTIC VAT Business Posting Group used as default for all rates created by this codeunit internal procedure AssistEditNoreplyMailbox() @@ -1178,9 +1173,14 @@ table 6930 "Expense Agent Setup" var CreateExpenseCountryVATRates: Codeunit "Create Expense VAT Rates"; begin + Rec.GetRecordOnce(); + if Rec."Default VAT Bus. Posting Group" = '' then begin + Rec."Default VAT Bus. Posting Group" := XDOMESTICTxt; + Rec.Modify(); + end; + CreateExpenseCountryVATRates.InsertDefaultRates(); - Rec.GetRecordOnce(); if not Rec."VAT Rates Applied" then begin Rec."VAT Rates Applied" := true; Rec.Modify(); diff --git a/src/Apps/W1/ExpenseAgent/test/src/ExpensePostingVATTest.Codeunit.al b/src/Apps/W1/ExpenseAgent/test/src/ExpensePostingVATTest.Codeunit.al index 9210b334394..2041332c04c 100644 --- a/src/Apps/W1/ExpenseAgent/test/src/ExpensePostingVATTest.Codeunit.al +++ b/src/Apps/W1/ExpenseAgent/test/src/ExpensePostingVATTest.Codeunit.al @@ -26,7 +26,6 @@ codeunit 148330 "Expense Posting VAT Test" LibraryExpense: Codeunit "Library - Expense"; LibraryPurchase: Codeunit "Library - Purchase"; LibraryTestInitialize: Codeunit "Library - Test Initialize"; - LibraryNonDeductibleVAT: Codeunit "Library - NonDeductible VAT"; LibraryUtility: Codeunit "Library - Utility"; LibraryVariableStorage: Codeunit "Library - Variable Storage"; IsInitialized: Boolean; @@ -1131,8 +1130,6 @@ codeunit 148330 "Expense Posting VAT Test" ExpenseAgentSetup.Validate("Default VAT Bus. Posting Group", VATBusinessPostingGroup.Code); ExpenseAgentSetup.Modify(true); - LibraryNonDeductibleVAT.EnableNonDeductibleVAT(); - LibraryERMCountryData.UpdateJournalTemplMandatory(false); LibraryERMCountryData.UpdateLocalData(); LibraryExpense.SetupNumberSeriesInExpenseMgmt(); From da456521592de0099f7583cbcd2293e926b3c601 Mon Sep 17 00:00:00 2001 From: Alexander Yakunin Date: Tue, 25 Aug 2026 20:53:11 +0200 Subject: [PATCH 24/29] Resolve comments --- .../src/Common/ExpenseAgentAPIValidation.Codeunit.al | 1 - .../Expense/Tables/ExpenseVATSpecification.Table.al | 11 +++++++++++ .../test/src/ExpensePostingVATTest.Codeunit.al | 2 +- 3 files changed, 12 insertions(+), 2 deletions(-) diff --git a/src/Apps/W1/ExpenseAgent/app/src/Common/ExpenseAgentAPIValidation.Codeunit.al b/src/Apps/W1/ExpenseAgent/app/src/Common/ExpenseAgentAPIValidation.Codeunit.al index c301821b843..ba65a32fc36 100644 --- a/src/Apps/W1/ExpenseAgent/app/src/Common/ExpenseAgentAPIValidation.Codeunit.al +++ b/src/Apps/W1/ExpenseAgent/app/src/Common/ExpenseAgentAPIValidation.Codeunit.al @@ -11,7 +11,6 @@ using System.Environment.Configuration; codeunit 6993 "Expense Agent API Validation" { Access = Internal; - EventSubscriberInstance = StaticAutomatic; InherentEntitlements = X; InherentPermissions = X; diff --git a/src/Apps/W1/ExpenseAgent/app/src/Expense/Tables/ExpenseVATSpecification.Table.al b/src/Apps/W1/ExpenseAgent/app/src/Expense/Tables/ExpenseVATSpecification.Table.al index 94f00c249bd..122061e957a 100644 --- a/src/Apps/W1/ExpenseAgent/app/src/Expense/Tables/ExpenseVATSpecification.Table.al +++ b/src/Apps/W1/ExpenseAgent/app/src/Expense/Tables/ExpenseVATSpecification.Table.al @@ -264,12 +264,14 @@ table 6918 "Expense VAT Specification" trigger OnModify() begin + TestStatusOpenOfExpense(); if Source = Source::Agent then error(ModifyOrDeleteErr); end; trigger OnDelete() begin + TestStatusOpenOfExpense(); if Source = Source::Agent then error(ModifyOrDeleteErr); end; @@ -382,4 +384,13 @@ table 6918 "Expense VAT Specification" Rec.Reasoning.CreateInStream(InStream, TextEncoding::UTF8); InStream.ReadText(Result); end; + + local procedure TestStatusOpenOfExpense() + var + ExpenseRecord: Record Expense; + begin + ExpenseRecord.SetLoadFields(Status); + ExpenseRecord.Get("Expense No."); + ExpenseRecord.TestStatusOpen(); + end; } diff --git a/src/Apps/W1/ExpenseAgent/test/src/ExpensePostingVATTest.Codeunit.al b/src/Apps/W1/ExpenseAgent/test/src/ExpensePostingVATTest.Codeunit.al index 2041332c04c..92b4aca5a06 100644 --- a/src/Apps/W1/ExpenseAgent/test/src/ExpensePostingVATTest.Codeunit.al +++ b/src/Apps/W1/ExpenseAgent/test/src/ExpensePostingVATTest.Codeunit.al @@ -713,7 +713,7 @@ codeunit 148330 "Expense Posting VAT Test" ExpenseReportHeader.Get(ExpenseReportHeader."No."); // [WHEN] The reimbursement currency factor is changed to 2 after reopening the report. - LibraryVariableStorage.Enqueue(StrSubstNo(CanModifyLinesQst, ExpenseReportHeader.FieldName("Reimbursement Currency Factor"))); + LibraryVariableStorage.Enqueue(StrSubstNo(CanModifyLinesQst, ExpenseReportHeader.FieldCaption("Reimbursement Currency Factor"))); LibraryVariableStorage.Enqueue(true); ExpenseReportHeader.Validate("Reimbursement Currency Factor", 2); ExpenseReportHeader.Modify(true); From 1daa8904864f69a547e59fd8bb490be81bea74c9 Mon Sep 17 00:00:00 2001 From: Alexander Yakunin Date: Fri, 28 Aug 2026 11:53:14 +0200 Subject: [PATCH 25/29] Update obsolete tags to 30.0 --- .../app/src/ExpenseReport/Pages/ExpenseReport.Page.al | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) 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 3d99b2800f2..25eb6c92c33 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 @@ -228,7 +228,7 @@ page 6910 "Expense Report" SubPageLink = "No." = field("No."); ObsoleteReason = 'Replaced by Expense Report FactBox'; ObsoleteState = Pending; - ObsoleteTag = '29.0'; + ObsoleteTag = '30.0'; Visible = false; } #pragma warning restore AL0432 @@ -510,7 +510,7 @@ page 6910 "Expense Report" Visible = false; ObsoleteReason = 'Replaced by Expense Report Statistics'; ObsoleteState = Pending; - ObsoleteTag = '29.0'; + ObsoleteTag = '30.0'; } #endif action("Spend Request") @@ -670,7 +670,7 @@ page 6910 "Expense Report" { ObsoleteReason = 'Replaced by Expense Report Statistics'; ObsoleteState = Pending; - ObsoleteTag = '29.0'; + ObsoleteTag = '30.0'; } #endif actionref("Spend Request_Promoted"; "Spend Request") From 4eba33605b5f5585d6547683de5e08a27132c605 Mon Sep 17 00:00:00 2001 From: Alexander Yakunin Date: Fri, 28 Aug 2026 12:38:37 +0200 Subject: [PATCH 26/29] Fix obsolete tag --- .../app/src/ExpenseReport/Pages/ExpenseReport.Page.al | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) 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 25eb6c92c33..22b4e4f2fa6 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 @@ -228,7 +228,7 @@ page 6910 "Expense Report" SubPageLink = "No." = field("No."); ObsoleteReason = 'Replaced by Expense Report FactBox'; ObsoleteState = Pending; - ObsoleteTag = '30.0'; + ObsoleteTag = '29.0'; Visible = false; } #pragma warning restore AL0432 @@ -498,7 +498,7 @@ page 6910 "Expense Report" CurrPage.SaveRecord(); end; } -#if not CLEAN29 +#if not CLEAN30 action(VATSpecification) { ApplicationArea = Basic, Suite; @@ -665,7 +665,7 @@ page 6910 "Expense Report" actionref(dimension_Promoted; Dimensions) { } -#if not CLEAN29 +#if not CLEAN30 actionref(VATSpecification_Promoted; VATSpecification) { ObsoleteReason = 'Replaced by Expense Report Statistics'; From 4866ade5ebbe2d5477672b8415235e52a8aa0198 Mon Sep 17 00:00:00 2001 From: Alexander Yakunin Date: Fri, 28 Aug 2026 19:16:25 +0200 Subject: [PATCH 27/29] Fix remaining review comments --- .../app/src/APIs/ExpenseVATSpecAPI.Page.al | 1 + .../Common/Enums/ExpenseVATSpecSource.Enum.al | 8 +- .../Pages/ExpenseVATSpecification.Page.al | 5 - .../Codeunits/ExpenseReportPost.Codeunit.al | 33 +++- .../UpgradeExpReportVATSpec.Codeunit.al | 2 + .../Tables/ExpenseReportLine.Table.al | 17 +- .../Tables/ExpenseReportLineVATSpec.Table.al | 4 +- .../Tables/PostedExpRepLineVATSpec.Table.al | 13 +- .../InstallExpenseAgentSetup.Codeunit.al | 5 +- .../src/ExpensePostingVATTest.Codeunit.al | 150 +++++++++++++++++- .../test/src/ExpenseReportTest.Codeunit.al | 14 ++ 11 files changed, 220 insertions(+), 32 deletions(-) diff --git a/src/Apps/W1/ExpenseAgent/app/src/APIs/ExpenseVATSpecAPI.Page.al b/src/Apps/W1/ExpenseAgent/app/src/APIs/ExpenseVATSpecAPI.Page.al index cd694743783..3671ca6406c 100644 --- a/src/Apps/W1/ExpenseAgent/app/src/APIs/ExpenseVATSpecAPI.Page.al +++ b/src/Apps/W1/ExpenseAgent/app/src/APIs/ExpenseVATSpecAPI.Page.al @@ -92,6 +92,7 @@ page 7085 "Expense VAT Spec. API" field(source; Rec.Source) { Caption = 'Source'; + Editable = false; } field(confidence; Rec.Confidence) { diff --git a/src/Apps/W1/ExpenseAgent/app/src/Common/Enums/ExpenseVATSpecSource.Enum.al b/src/Apps/W1/ExpenseAgent/app/src/Common/Enums/ExpenseVATSpecSource.Enum.al index 2b1a65cd120..201690a4b0c 100644 --- a/src/Apps/W1/ExpenseAgent/app/src/Common/Enums/ExpenseVATSpecSource.Enum.al +++ b/src/Apps/W1/ExpenseAgent/app/src/Common/Enums/ExpenseVATSpecSource.Enum.al @@ -11,13 +11,13 @@ enum 6921 "Expense VAT Spec Source" { Extensible = false; - value(0; Agent) + value(0; Manual) { - Caption = 'Agent'; + Caption = 'Manual'; } - value(1; Manual) + value(1; Agent) { - Caption = 'Manual'; + Caption = 'Agent'; } value(2; Override) { diff --git a/src/Apps/W1/ExpenseAgent/app/src/Expense/Pages/ExpenseVATSpecification.Page.al b/src/Apps/W1/ExpenseAgent/app/src/Expense/Pages/ExpenseVATSpecification.Page.al index 43a54638637..3bda2bdcd29 100644 --- a/src/Apps/W1/ExpenseAgent/app/src/Expense/Pages/ExpenseVATSpecification.Page.al +++ b/src/Apps/W1/ExpenseAgent/app/src/Expense/Pages/ExpenseVATSpecification.Page.al @@ -67,9 +67,4 @@ page 7084 "Expense VAT Specification" } } - trigger OnInsertRecord(BelowxRec: Boolean): Boolean - begin - Rec.Source := Rec.Source::"Manual"; - exit(true); - end; } 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 e151365056c..17411a29066 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 @@ -80,6 +80,7 @@ codeunit 6987 "Expense Report-Post" NotApprovedForVATReclaimErr: Label 'VAT Reclaim Status is not set for Line with Expense Category %1 and Expense Subcategory %2.', Comment = '%1 = Expense Category, %2 = Expense Subcategory'; PendingVATSpecTitleErr: Label 'VAT reclaim status is pending'; PendingVATSpecDetailedErr: Label 'Open the VAT specification line and approve or reject the reclaim, and then retry posting the expense report.'; + RoundingDifferenceTooLargeErr: Label 'The difference between expense report line %1 and its posted amounts exceeds the currency rounding precision. The difference is %2 in reimbursement currency and %3 in local currency.', Comment = '%1 = Expense report line number, %2 = Difference in reimbursement currency, %3 = Difference in local currency'; AgentVATSpecificationsPostedLbl: Label 'Agent-authored VAT specifications posted.', Locked = true; ShowItLbl: Label 'Show it'; @@ -582,7 +583,9 @@ codeunit 6987 "Expense Report-Post" local procedure PostRefundableJnlLineFromSpecs(ExpenseReportHeader: Record "Expense Report Header"; ExpenseReportLine: Record "Expense Report Line"; PostedExpReportLine: Record "Posted Expense Report Line"; var ExpenseReportLineVATSpec: Record "Expense Report Line VAT Spec."; var RefundableAmount: Decimal; var RefundableAmountLCY: Decimal) var GenJournalLine: Record "Gen. Journal Line"; - JobLedgEntryPosted: Boolean; + ProjectGenJournalLine: Record "Gen. Journal Line"; + ProjectAmountLCY: Decimal; + ProjectJournalLineInitialized: Boolean; GlobalEntryCopied: Boolean; begin // Each spec row produces its own Gen. Journal Line and VAT Entry. @@ -594,10 +597,12 @@ codeunit 6987 "Expense Report-Post" SetupNonDeductibleVATForSpec(GenJournalLine, ExpenseReportLineVATSpec); SetupSourceCodeAndDimensions(GenJournalLine, ExpenseReportLine."Dimension Set ID"); - if (not JobLedgEntryPosted) and (ExpenseReportLine."Job No." <> '') then begin - PostedExpReportLine."Job Ledger Entry No." := PostProjectJnlLine(PostedExpReportLine, PostedExpenseReportHeader, GenJournalLine); - PostedExpReportLine.Modify(); - JobLedgEntryPosted := true; + if ExpenseReportLine."Job No." <> '' then begin + if not ProjectJournalLineInitialized then begin + ProjectGenJournalLine := GenJournalLine; + ProjectJournalLineInitialized := true; + end; + ProjectAmountLCY += GenJournalLine."Amount (LCY)"; end; if not GlobalEntryCopied then begin @@ -614,6 +619,12 @@ codeunit 6987 "Expense Report-Post" RefundableAmountLCY += ExpenseReportLineVATSpec."VAT Base Amount (LCY)" + ExpenseReportLineVATSpec."VAT Amount (LCY)"; until ExpenseReportLineVATSpec.Next() = 0; + if ProjectJournalLineInitialized then begin + ProjectGenJournalLine."Amount (LCY)" := ProjectAmountLCY; + PostedExpReportLine."Job Ledger Entry No." := PostProjectJnlLine(PostedExpReportLine, PostedExpenseReportHeader, ProjectGenJournalLine); + PostedExpReportLine.Modify(); + end; + // Employee paid accumulator: the employee paid the gross reimbursable amount for the whole line. if ExpenseReportLine."Reimbursement Type" = ExpenseReportLine."Reimbursement Type"::"Employee Paid" then begin AmountToEmployee += ExpenseReportLine."Reimbursable Amount"; @@ -1113,20 +1124,30 @@ codeunit 6987 "Expense Report-Post" local procedure PostRoundingDifferenceOnCurrency(ExpenseReportHeader: Record "Expense Report Header"; ExpenseReportLine: Record "Expense Report Line"; AmountToPost: Decimal; AmountToPostLCY: Decimal) var + LCYCurrency: Record Currency; + ReimbursementCurrency: Record Currency; ExpenseCategory: Record "Expense Category"; ExpensePostingGroup: Record "Expense Posting Group"; GenJournalLine: Record "Gen. Journal Line"; AccNo: Code[20]; AmountForAccountSelection: Decimal; + AmountRoundingPrecision: Decimal; begin + LCYCurrency.Initialize(''); + ReimbursementCurrency.Initialize(ExpenseReportHeader."Reimbursement Currency Code"); if (AmountToPost = 0) and (AmountToPostLCY = 0) then exit; ExpenseCategory.Get(ExpenseReportLine."Expense Category"); ExpensePostingGroup.Get(ExpenseCategory."Posting Group"); AmountForAccountSelection := AmountToPostLCY; - if AmountForAccountSelection = 0 then + AmountRoundingPrecision := LCYCurrency."Amount Rounding Precision"; + if AmountForAccountSelection = 0 then begin AmountForAccountSelection := AmountToPost; + AmountRoundingPrecision := ReimbursementCurrency."Amount Rounding Precision"; + end; + if Abs(AmountForAccountSelection) > AmountRoundingPrecision then + Error(RoundingDifferenceTooLargeErr, ExpenseReportLine."Line No.", AmountToPost, AmountToPostLCY); if AmountForAccountSelection > 0 then begin ExpensePostingGroup.TestField("Debit Rounding Account"); AccNo := ExpensePostingGroup."Debit Rounding Account"; diff --git a/src/Apps/W1/ExpenseAgent/app/src/ExpenseReport/Codeunits/UpgradeExpReportVATSpec.Codeunit.al b/src/Apps/W1/ExpenseAgent/app/src/ExpenseReport/Codeunits/UpgradeExpReportVATSpec.Codeunit.al index 4f409f55f48..5169b9411e6 100644 --- a/src/Apps/W1/ExpenseAgent/app/src/ExpenseReport/Codeunits/UpgradeExpReportVATSpec.Codeunit.al +++ b/src/Apps/W1/ExpenseAgent/app/src/ExpenseReport/Codeunits/UpgradeExpReportVATSpec.Codeunit.al @@ -148,6 +148,7 @@ codeunit 7105 "Upgrade Exp. Report VAT Spec" if (not HasCachedDocumentNo) or (CachedDocumentNo <> ExpenseReportLineVATSpec."Document No.") then begin CachedDocumentNo := ExpenseReportLineVATSpec."Document No."; HasCachedDocumentNo := true; + ExpenseReportHeader.SetLoadFields("Reimbursement Currency Code", "Posting Date", "Reimbursement Currency Factor"); HeaderFound := ExpenseReportHeader.Get(CachedDocumentNo); end; @@ -194,6 +195,7 @@ codeunit 7105 "Upgrade Exp. Report VAT Spec" if (not HasCachedExpenseReportNo) or (CachedExpenseReportNo <> PostedExpenseReportLineVATSpec."Expense Report No.") then begin CachedExpenseReportNo := PostedExpenseReportLineVATSpec."Expense Report No."; HasCachedExpenseReportNo := true; + PostedExpenseReportHeader.SetLoadFields("Reimbursement Currency Code", "Posting Date", "Reimbursement Currency Factor"); HeaderFound := PostedExpenseReportHeader.Get(CachedExpenseReportNo); end; diff --git a/src/Apps/W1/ExpenseAgent/app/src/ExpenseReport/Tables/ExpenseReportLine.Table.al b/src/Apps/W1/ExpenseAgent/app/src/ExpenseReport/Tables/ExpenseReportLine.Table.al index f85b5f0204e..e26abf4ce40 100644 --- a/src/Apps/W1/ExpenseAgent/app/src/ExpenseReport/Tables/ExpenseReportLine.Table.al +++ b/src/Apps/W1/ExpenseAgent/app/src/ExpenseReport/Tables/ExpenseReportLine.Table.al @@ -1593,6 +1593,7 @@ table 6907 "Expense Report Line" ExpenseSubcategory: Record "Expense Subcategory"; BaseDescription: Text[100]; PostingDescriptionSuffix: Text; + StoredSuffixLength: Integer; begin BaseDescription := Description; if ("Expense Subcategory Code" <> '') and @@ -1600,11 +1601,19 @@ table 6907 "Expense Report Line" (ExpenseSubcategory."Posting Description" <> '') then begin PostingDescriptionSuffix := ' / ' + ExpenseSubcategory."Posting Description"; -#pragma warning disable AA0139 - if BaseDescription.EndsWith(PostingDescriptionSuffix) then - BaseDescription := CopyStr(BaseDescription, 1, StrLen(BaseDescription) - StrLen(PostingDescriptionSuffix)); + StoredSuffixLength := StrLen(PostingDescriptionSuffix); + if StoredSuffixLength > MaxStrLen(BaseDescription) then + StoredSuffixLength := MaxStrLen(BaseDescription); + while (StoredSuffixLength >= StrLen(' / ')) and + (not BaseDescription.EndsWith(CopyStr(PostingDescriptionSuffix, 1, StoredSuffixLength))) + do + StoredSuffixLength -= 1; + if (StoredSuffixLength = StrLen(PostingDescriptionSuffix)) or + ((StrLen(BaseDescription) = MaxStrLen(BaseDescription)) and (StoredSuffixLength >= StrLen(' / '))) + then + BaseDescription := CopyStr( + DelStr(BaseDescription, StrLen(BaseDescription) - StoredSuffixLength + 1), 1, MaxStrLen(BaseDescription)); end; -#pragma warning restore AA0139 if (ExpenseSubcategoryCode = '') or (not ExpenseSubcategory.Get(ExpenseCategoryCode, ExpenseSubcategoryCode)) or diff --git a/src/Apps/W1/ExpenseAgent/app/src/ExpenseReport/Tables/ExpenseReportLineVATSpec.Table.al b/src/Apps/W1/ExpenseAgent/app/src/ExpenseReport/Tables/ExpenseReportLineVATSpec.Table.al index c3aadb3b803..227f1601aac 100644 --- a/src/Apps/W1/ExpenseAgent/app/src/ExpenseReport/Tables/ExpenseReportLineVATSpec.Table.al +++ b/src/Apps/W1/ExpenseAgent/app/src/ExpenseReport/Tables/ExpenseReportLineVATSpec.Table.al @@ -441,8 +441,10 @@ table 6922 "Expense Report Line VAT Spec." local procedure GetExpenseReportLine() begin - if ("Document No." <> ExpenseReportLine."Document No.") or ("Document Line No." <> ExpenseReportLine."Line No.") then + if ("Document No." <> ExpenseReportLine."Document No.") or ("Document Line No." <> ExpenseReportLine."Line No.") then begin + ExpenseReportLine.SetLoadFields("Expense Date"); ExpenseReportLine.Get("Document No.", "Document Line No."); + end; end; local procedure GetReimbursementCurrencyCode(): Code[20] diff --git a/src/Apps/W1/ExpenseAgent/app/src/ExpenseReport/Tables/PostedExpRepLineVATSpec.Table.al b/src/Apps/W1/ExpenseAgent/app/src/ExpenseReport/Tables/PostedExpRepLineVATSpec.Table.al index 016fa474b51..53e405dc3fd 100644 --- a/src/Apps/W1/ExpenseAgent/app/src/ExpenseReport/Tables/PostedExpRepLineVATSpec.Table.al +++ b/src/Apps/W1/ExpenseAgent/app/src/ExpenseReport/Tables/PostedExpRepLineVATSpec.Table.al @@ -284,13 +284,18 @@ table 6934 "Posted Exp. Rep. Line VAT Spec" } } - local procedure GetReimbursementCurrencyCode(): Code[20] var PostedExpenseReportHeader: Record "Posted Expense Report Header"; + + local procedure GetReimbursementCurrencyCode(): Code[20] begin - PostedExpenseReportHeader.SetLoadFields("Reimbursement Currency Code"); - if PostedExpenseReportHeader.Get("Expense Report No.") then - exit(PostedExpenseReportHeader."Reimbursement Currency Code"); + if "Expense Report No." <> PostedExpenseReportHeader."No." then begin + PostedExpenseReportHeader.SetLoadFields("Reimbursement Currency Code"); + if not PostedExpenseReportHeader.Get("Expense Report No.") then + Clear(PostedExpenseReportHeader); + end; + + exit(PostedExpenseReportHeader."Reimbursement Currency Code"); end; /// Stores a UTF-8 reclaim justification text into the blob. diff --git a/src/Apps/W1/ExpenseAgent/app/src/Setup/Codeunits/InstallExpenseAgentSetup.Codeunit.al b/src/Apps/W1/ExpenseAgent/app/src/Setup/Codeunits/InstallExpenseAgentSetup.Codeunit.al index 7ab98a9c072..0c10040296a 100644 --- a/src/Apps/W1/ExpenseAgent/app/src/Setup/Codeunits/InstallExpenseAgentSetup.Codeunit.al +++ b/src/Apps/W1/ExpenseAgent/app/src/Setup/Codeunits/InstallExpenseAgentSetup.Codeunit.al @@ -20,9 +20,12 @@ codeunit 6992 "Install Expense Agent Setup" var ExpenseAgentSetup: Record "Expense Agent Setup"; UpgradeExpReportVATSpec: Codeunit "Upgrade Exp. Report VAT Spec"; + AppInfo: ModuleInfo; begin ExpenseAgentSetup.InitRecord(); - UpgradeExpReportVATSpec.SetBackfillReimbursementAmountsUpgradeTag(); + NavApp.GetCurrentModuleInfo(AppInfo); + if AppInfo.DataVersion() = Version.Create('0.0.0.0') then + UpgradeExpReportVATSpec.SetBackfillReimbursementAmountsUpgradeTag(); end; trigger OnInstallAppPerDatabase() diff --git a/src/Apps/W1/ExpenseAgent/test/src/ExpensePostingVATTest.Codeunit.al b/src/Apps/W1/ExpenseAgent/test/src/ExpensePostingVATTest.Codeunit.al index 92b4aca5a06..323ede7d9fc 100644 --- a/src/Apps/W1/ExpenseAgent/test/src/ExpensePostingVATTest.Codeunit.al +++ b/src/Apps/W1/ExpenseAgent/test/src/ExpensePostingVATTest.Codeunit.al @@ -10,6 +10,8 @@ using Microsoft.Finance.GeneralLedger.Ledger; using Microsoft.Finance.VAT.Ledger; using Microsoft.Finance.VAT.Setup; using Microsoft.HumanResources.Employee; +using Microsoft.Projects.Project.Job; +using Microsoft.Projects.Project.Ledger; using Microsoft.Purchases.Vendor; using System.Environment; using System.TestLibraries.Utilities; @@ -24,6 +26,7 @@ codeunit 148330 "Expense Posting VAT Test" Assert: Codeunit "Assert"; LibraryERM: Codeunit "Library - ERM"; LibraryExpense: Codeunit "Library - Expense"; + LibraryJob: Codeunit "Library - Job"; LibraryPurchase: Codeunit "Library - Purchase"; LibraryTestInitialize: Codeunit "Library - Test Initialize"; LibraryUtility: Codeunit "Library - Utility"; @@ -33,6 +36,7 @@ codeunit 148330 "Expense Posting VAT Test" NotApprovedForVATReclaimCategoryErr: Label 'VAT Reclaim Status is not set for Line with Expense Category %1.', Comment = '%1 = Expense Category'; NotApprovedForVATReclaimErr: Label 'VAT Reclaim Status is not set for Line with Expense Category %1 and Expense Subcategory %2.', Comment = '%1 = Expense Category, %2 = Expense Subcategory'; ModifyOrDeleteAgentVATSpecErr: Label 'Modifications and delete are not allowed for records created by the Expense Agent API.'; + RoundingDifferenceTooLargeErr: Label 'exceeds the currency rounding precision'; [Test] [HandlerFunctions('ExpensesModalPageHandler,ConfirmHandler')] @@ -85,6 +89,56 @@ codeunit 148330 "Expense Posting VAT Test" VerifyGLEntry(PostedExpenseReportHeader."No.", GetRefundableDebitAccount(ExpenseCategory.Code), 144.09); end; + [Test] + [HandlerFunctions('ExpensesModalPageHandler,ConfirmHandler')] + procedure MultipleVATSpecificationsPostCompleteAmountToProject() + var + Expense: Record Expense; + ExpenseUser: Record "Expense User"; + ExpenseCategory: Record "Expense Category"; + ExpenseSubCategory: array[4] of Record "Expense Subcategory"; + ExpenseReportHeader: Record "Expense Report Header"; + PostedExpenseReportHeader: Record "Posted Expense Report Header"; + PostedExpenseReportLine: Record "Posted Expense Report Line"; + Job: Record Job; + JobTask: Record "Job Task"; + JobLedgerEntry: Record "Job Ledger Entry"; + VATPostingSetup: array[3] of Record "VAT Posting Setup"; + begin + // [SCENARIO] A project receives the complete expense-line amount when the line has multiple VAT specifications. + Initialize(); + + // [GIVEN] An expense assigned to a project with two VAT specifications whose LCY bases total 200. + CreateExpenseUserAndCategory(ExpenseUser, ExpenseCategory); + CreateSubcategoryWithVATRate(ExpenseSubCategory[1], ExpenseCategory.Code, 10, VATPostingSetup[1]); + CreateSubcategoryWithVATRate(ExpenseSubCategory[2], ExpenseCategory.Code, 20, VATPostingSetup[2]); + CreateExpenseWithHotelItemizations(Expense, ExpenseUser, ExpenseCategory, ExpenseSubCategory, VATPostingSetup, 110, 120, 0, 0); + LibraryJob.CreateJob(Job); + LibraryJob.CreateJobTask(Job, JobTask); + Expense.PerformManualReopen(Expense); + Expense.Get(Expense."No."); + Expense.Validate("Job No.", JobTask."Job No."); + Expense.Validate("Job Task No.", JobTask."Job Task No."); + Expense.Modify(true); + ReleaseExpenseAndUpdateAccounts(Expense, ExpenseUser); + + // [WHEN] The expense report is posted. + PostExpenseReport(ExpenseReportHeader, Expense, ExpenseUser); + + // [THEN] One project ledger entry contains the sum of both specification journal amounts. + FindPostedExpenseReport(PostedExpenseReportHeader, Expense); + PostedExpenseReportLine.SetRange("Document No.", PostedExpenseReportHeader."No."); + PostedExpenseReportLine.FindFirst(); +#pragma warning disable AA0210 + JobLedgerEntry.SetRange("Expense Report No.", PostedExpenseReportHeader."No."); + JobLedgerEntry.SetRange("Expense Report Line No.", PostedExpenseReportLine."Line No."); +#pragma warning restore AA0210 + JobLedgerEntry.FindFirst(); + Assert.RecordCount(JobLedgerEntry, 1); + Assert.AreNearlyEqual(200, JobLedgerEntry."Unit Cost (LCY)", 0.01, 'Project unit cost must include all VAT specification journal amounts.'); + Assert.AreNearlyEqual(200, JobLedgerEntry."Unit Price (LCY)", 0.01, 'Project unit price must include all VAT specification journal amounts.'); + end; + [Test] [HandlerFunctions('ExpensesModalPageHandler,ConfirmHandler')] procedure VATEntriesPerHotelSubcat() @@ -410,6 +464,59 @@ codeunit 148330 "Expense Posting VAT Test" Assert.RecordCount(PostedExpenseReportHeader, 0); end; + [Test] + [HandlerFunctions('ExpensesModalPageHandler,ConfirmHandler')] + procedure MaterialVATSpecificationDifferenceBlocksPosting() + var + Expense: Record Expense; + ExpenseUser: Record "Expense User"; + ExpenseCategory: Record "Expense Category"; + ExpenseSubCategory: array[4] of Record "Expense Subcategory"; + ExpenseReportHeader: Record "Expense Report Header"; + ExpenseReportLine: Record "Expense Report Line"; + ExpenseReportLineVATSpec: Record "Expense Report Line VAT Spec."; + PostedExpenseReportHeader: Record "Posted Expense Report Header"; + VATPostingSetup: array[3] of Record "VAT Posting Setup"; + CreateExpenseReport: Codeunit "Create Expense Report"; + ExpenseReportPost: Codeunit "Expense Report-Post"; + begin + // [SCENARIO] A material difference between an expense report line and its VAT specifications blocks posting. + Initialize(); + + // [GIVEN] An LCY expense report whose VAT specification total is lower than the line amount by 1.00. + CreateExpenseUserAndCategory(ExpenseUser, ExpenseCategory); + CreateSubcategoryWithVATRate(ExpenseSubCategory[1], ExpenseCategory.Code, 10, VATPostingSetup[1]); + CreateExpenseWithHotelItemizations(Expense, ExpenseUser, ExpenseCategory, ExpenseSubCategory, VATPostingSetup, 110, 0, 0, 0); + LibraryExpense.CreateExpenseReport(ExpenseReportHeader, ExpenseUser."No.", '', Expense."VAT Bus. Posting Group"); + AddExpensesToReport(CreateExpenseReport, ExpenseReportHeader, Expense."No."); + UpdateExpenseReportLinesWithVendor(ExpenseReportHeader); + ExpenseReportHeader.PerformManualRelease(); + ExpenseReportLineVATSpec.SetRange("Document No.", ExpenseReportHeader."No."); + ExpenseReportLineVATSpec.SetFilter("Document Line No.", '<>%1', 0); + ExpenseReportLineVATSpec.FindFirst(); + ExpenseReportLineVATSpec."VAT Base Amount (RCY)" -= 1; + ExpenseReportLineVATSpec."VAT Base Amount (LCY)" -= 1; + ExpenseReportLineVATSpec.Modify(false); + ExpenseReportLine.SetRange("Document No.", ExpenseReportHeader."No."); + ExpenseReportLine.FindFirst(); + ExpenseReportLineVATSpec.CalcSums("VAT Base Amount (RCY)", "VAT Amount (RCY)", "VAT Base Amount (LCY)", "VAT Amount (LCY)"); + Assert.AreNearlyEqual( + 1, ExpenseReportLine."Reimbursable Amount" - ExpenseReportLineVATSpec."VAT Base Amount (RCY)" - ExpenseReportLineVATSpec."VAT Amount (RCY)", 0.01, + 'The test must create a material reimbursement-currency difference.'); + Assert.AreNearlyEqual( + 1, ExpenseReportLine."Reimbursable Amount (LCY)" - ExpenseReportLineVATSpec."VAT Base Amount (LCY)" - ExpenseReportLineVATSpec."VAT Amount (LCY)", 0.01, + 'The test must create a material local-currency difference.'); + + // [WHEN] Posting is attempted. + asserterror PostExpenseReportWithConfirmation(ExpenseReportPost, ExpenseReportHeader); + + // [THEN] The material difference is not reclassified to an expense rounding account. + Assert.ExpectedError(RoundingDifferenceTooLargeErr); + LibraryVariableStorage.AssertEmpty(); + PostedExpenseReportHeader.SetRange("Expense User No.", ExpenseUser."No."); + Assert.RecordCount(PostedExpenseReportHeader, 0); + end; + [Test] [HandlerFunctions('ExpensesModalPageHandler,ConfirmHandler')] procedure VATSpecWithoutSubcategoryPendingStatusBlocksPosting() @@ -909,6 +1016,7 @@ codeunit 148330 "Expense Posting VAT Test" [Test] procedure AgentVATSpecificationAPIAcceptsNonSaaSCaller() var + Expense: Record Expense; ExpenseVATSpecification: Record "Expense VAT Specification"; EnvironmentInfo: Codeunit "Environment Information"; ExpenseVATSpecAPI: Page "Expense VAT Spec. API"; @@ -917,20 +1025,22 @@ codeunit 148330 "Expense Posting VAT Test" if EnvironmentInfo.IsSaaSInfrastructure() then exit; - // [GIVEN] A VAT specification with a business posting group. - ExpenseVATSpecification."VAT Bus. Posting Group" := 'LOCAL'; + // [GIVEN] A VAT specification whose relational fields have been validated against a persisted expense. + Initialize(); + CreateAgentVATSpecificationFixture(ExpenseVATSpecification, Expense); // [WHEN] A caller attempts to insert an agent-authored VAT specification through the API. ExpenseVATSpecAPI.InsertVATSpecification(ExpenseVATSpecification); // [THEN] The agent-authored VAT specification is inserted. Assert.AreEqual(ExpenseVATSpecification.Source::Agent, ExpenseVATSpecification.Source, 'The VAT specification source must be Agent.'); - Assert.IsTrue(ExpenseVATSpecification.Get(ExpenseVATSpecification."Expense No.", ExpenseVATSpecification."Line No."), 'The VAT specification must be inserted.'); + Assert.IsTrue(ExpenseVATSpecification.Get(Expense."No.", ExpenseVATSpecification."Line No."), 'The VAT specification must be inserted for the parent expense.'); end; [Test] procedure AgentVATSpecificationInsertAcceptsNonSaaSCaller() var + Expense: Record Expense; ExpenseVATSpecification: Record "Expense VAT Specification"; EnvironmentInfo: Codeunit "Environment Information"; begin @@ -938,14 +1048,16 @@ codeunit 148330 "Expense Posting VAT Test" if EnvironmentInfo.IsSaaSInfrastructure() then exit; - // [GIVEN] An agent-authored VAT specification. + // [GIVEN] A VAT specification whose relational fields have been validated against a persisted expense. + Initialize(); + CreateAgentVATSpecificationFixture(ExpenseVATSpecification, Expense); ExpenseVATSpecification.Source := ExpenseVATSpecification.Source::Agent; - // [WHEN] A caller attempts to insert an agent-authored VAT specification without running table triggers. - ExpenseVATSpecification.Insert(false); + // [WHEN] A caller attempts to insert an agent-authored VAT specification with table triggers. + ExpenseVATSpecification.Insert(true); // [THEN] The agent-authored VAT specification is inserted. - Assert.IsTrue(ExpenseVATSpecification.Get(ExpenseVATSpecification."Expense No.", ExpenseVATSpecification."Line No."), 'The VAT specification must be inserted.'); + Assert.IsTrue(ExpenseVATSpecification.Get(Expense."No.", ExpenseVATSpecification."Line No."), 'The VAT specification must be inserted for the parent expense.'); end; [Test] @@ -1385,6 +1497,30 @@ codeunit 148330 "Expense Posting VAT Test" ExpenseVATSpecification.Insert(true); end; + local procedure CreateAgentVATSpecificationFixture(var ExpenseVATSpecification: Record "Expense VAT Specification"; var Expense: Record Expense) + var + ExpenseCategory: Record "Expense Category"; + ExpenseSubcategory: Record "Expense Subcategory"; + ExpenseUser: Record "Expense User"; + VATPostingSetup: Record "VAT Posting Setup"; + begin + LibraryExpense.CreateExpenseUser(ExpenseUser); + LibraryExpense.CreateExpenseCategory(ExpenseCategory, ExpenseCategory."Reimbursement Type"::"Employee Paid", ExpenseCategory."Expense Detail Required"::" "); + CreateSubcategoryWithVATRate(ExpenseSubcategory, ExpenseCategory.Code, 20, VATPostingSetup); + LibraryExpense.CreateExpense(Expense, ExpenseUser."No.", ExpenseCategory.Code, ExpenseSubcategory.Code, '', true, '', 120); + Expense.Validate("VAT Bus. Posting Group", VATPostingSetup."VAT Bus. Posting Group"); + Expense.Validate("VAT Prod. Posting Group", VATPostingSetup."VAT Prod. Posting Group"); + Expense.Modify(true); + + ExpenseVATSpecification.Init(); + ExpenseVATSpecification.Validate("Expense No.", Expense."No."); + ExpenseVATSpecification.Validate("Expense Category", ExpenseCategory.Code); + ExpenseVATSpecification.Validate("Expense Subcategory", ExpenseSubcategory.Code); + ExpenseVATSpecification.Validate("VAT Bus. Posting Group", VATPostingSetup."VAT Bus. Posting Group"); + ExpenseVATSpecification.Validate("VAT Prod. Posting Group", VATPostingSetup."VAT Prod. Posting Group"); + ExpenseVATSpecification.Validate(Amount, Expense.Amount); + end; + local procedure UpdateExpenseReportLinesWithVendor(ExpenseReportHeader: Record "Expense Report Header") var ExpenseReportLine: Record "Expense Report Line"; diff --git a/src/Apps/W1/ExpenseAgent/test/src/ExpenseReportTest.Codeunit.al b/src/Apps/W1/ExpenseAgent/test/src/ExpenseReportTest.Codeunit.al index 877523d317c..5d5c92f96a0 100644 --- a/src/Apps/W1/ExpenseAgent/test/src/ExpenseReportTest.Codeunit.al +++ b/src/Apps/W1/ExpenseAgent/test/src/ExpenseReportTest.Codeunit.al @@ -3333,6 +3333,20 @@ codeunit 148306 "Expense Report Test" ExpenseReportLine.UpdatePostingDescription(ExpenseCategory.Code, ExpenseSubcategory[2].Code), 'The posting description must use the selected subcategory description.'); + // [WHEN] The original subcategory suffix was truncated by the description length limit. + BaseDescription := PadStr('', 95, 'B'); + ExpenseSubcategory[1]."Posting Description" := 'OLD SUFFIX'; + ExpenseSubcategory[1].Modify(); + ExpenseSubcategory[2]."Posting Description" := 'NEW SUFFIX'; + ExpenseSubcategory[2].Modify(); + ExpenseReportLine.Description := CopyStr(BaseDescription + ' / ' + ExpenseSubcategory[1]."Posting Description", 1, MaxStrLen(ExpenseReportLine.Description)); + + // [THEN] The stored part of the old suffix is removed before the new suffix is applied. + Assert.AreEqual( + CopyStr(BaseDescription + ' / ' + ExpenseSubcategory[2]."Posting Description", 1, MaxStrLen(ExpenseReportLine.Description)), + ExpenseReportLine.UpdatePostingDescription(ExpenseCategory.Code, ExpenseSubcategory[2].Code), + 'The truncated posting-description suffix must be replaced.'); + // [WHEN] The selected subcategory has no posting description. ExpenseSubcategory[2]."Posting Description" := ''; ExpenseSubcategory[2].Modify(); From 0c1756b6cdd4425d6e82f01ab128fa22e3af029a Mon Sep 17 00:00:00 2001 From: Alexander Yakunin Date: Sat, 29 Aug 2026 12:29:24 +0200 Subject: [PATCH 28/29] Revert breaking change in enum --- .../app/src/Common/Enums/ExpenseVATSpecSource.Enum.al | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/src/Apps/W1/ExpenseAgent/app/src/Common/Enums/ExpenseVATSpecSource.Enum.al b/src/Apps/W1/ExpenseAgent/app/src/Common/Enums/ExpenseVATSpecSource.Enum.al index 201690a4b0c..2b1a65cd120 100644 --- a/src/Apps/W1/ExpenseAgent/app/src/Common/Enums/ExpenseVATSpecSource.Enum.al +++ b/src/Apps/W1/ExpenseAgent/app/src/Common/Enums/ExpenseVATSpecSource.Enum.al @@ -11,13 +11,13 @@ enum 6921 "Expense VAT Spec Source" { Extensible = false; - value(0; Manual) + value(0; Agent) { - Caption = 'Manual'; + Caption = 'Agent'; } - value(1; Agent) + value(1; Manual) { - Caption = 'Agent'; + Caption = 'Manual'; } value(2; Override) { From 5f2e958c744b95dd69b9e60b0bbfd21658fed22f Mon Sep 17 00:00:00 2001 From: Alexander Yakunin Date: Sat, 29 Aug 2026 13:39:22 +0200 Subject: [PATCH 29/29] Fix tests --- .../ExpenseReport/Codeunits/ExpenseReportPost.Codeunit.al | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) 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 17411a29066..cd9c083fde6 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 @@ -1128,6 +1128,7 @@ codeunit 6987 "Expense Report-Post" ReimbursementCurrency: Record Currency; ExpenseCategory: Record "Expense Category"; ExpensePostingGroup: Record "Expense Posting Group"; + ExpenseReportLineVATSpec: Record "Expense Report Line VAT Spec."; GenJournalLine: Record "Gen. Journal Line"; AccNo: Code[20]; AmountForAccountSelection: Decimal; @@ -1146,8 +1147,11 @@ codeunit 6987 "Expense Report-Post" AmountForAccountSelection := AmountToPost; AmountRoundingPrecision := ReimbursementCurrency."Amount Rounding Precision"; end; - if Abs(AmountForAccountSelection) > AmountRoundingPrecision then - Error(RoundingDifferenceTooLargeErr, ExpenseReportLine."Line No.", AmountToPost, AmountToPostLCY); + ExpenseReportLineVATSpec.SetRange("Document No.", ExpenseReportLine."Document No."); + ExpenseReportLineVATSpec.SetRange("Document Line No.", ExpenseReportLine."Line No."); + if not ExpenseReportLineVATSpec.IsEmpty() then + if Abs(AmountForAccountSelection) > AmountRoundingPrecision then + Error(RoundingDifferenceTooLargeErr, ExpenseReportLine."Line No.", AmountToPost, AmountToPostLCY); if AmountForAccountSelection > 0 then begin ExpensePostingGroup.TestField("Debit Rounding Account"); AccNo := ExpensePostingGroup."Debit Rounding Account";