From 9293f609d7781f8b234cf22d928169df2539bd24 Mon Sep 17 00:00:00 2001 From: Djordje Cenic Date: Tue, 22 Sep 2026 15:14:52 +0200 Subject: [PATCH 1/7] [29.x][VIES Integration] Disallow running codeunit 248 in background and API sessions --- .../VATLookupExtDataHndl.Codeunit.al | 38 +++++++++++++++++++ 1 file changed, 38 insertions(+) diff --git a/src/Layers/W1/BaseApp/Finance/VAT/Registration/VATLookupExtDataHndl.Codeunit.al b/src/Layers/W1/BaseApp/Finance/VAT/Registration/VATLookupExtDataHndl.Codeunit.al index 0a9c7ed0f9d..d7fa2a2a54e 100644 --- a/src/Layers/W1/BaseApp/Finance/VAT/Registration/VATLookupExtDataHndl.Codeunit.al +++ b/src/Layers/W1/BaseApp/Finance/VAT/Registration/VATLookupExtDataHndl.Codeunit.al @@ -7,8 +7,10 @@ namespace Microsoft.Finance.VAT.Registration; using Microsoft.CRM.Contact; using Microsoft.Sales.Customer; using System; +using System.Environment; using System.Integration; using System.Reflection; +using System.Telemetry; using System.Utilities; using System.Xml; @@ -25,6 +27,8 @@ codeunit 248 "VAT Lookup Ext. Data Hndl" var IsHandled: Boolean; begin + BlockAutomatedSessionAccess(); + InitVATRegistrationLog(Rec); VATRegistrationLog := Rec; @@ -48,8 +52,42 @@ codeunit 248 "VAT Lookup Ext. Data Hndl" EUVATRegNoValidationServiceTok: Label 'EUVATRegNoValidationServiceTelemetryCategoryTok', Locked = true; ValidationSuccessfulMsg: Label 'The VAT reg. no. validation was successful', Locked = true; ValidationFailureMsg: Label 'The VAT reg. no. validation failed. Http request failure', Locked = true; + AutomatedAccessBlockedErr: Label 'VAT registration number validation against the EU VIES service is not available from API or background (non-interactive) sessions. Verify VAT registration numbers interactively instead.'; + AutomatedAccessBlockedMsg: Label 'The VAT reg. no. validation was blocked because it was invoked from an API or background session.', Locked = true; + SecurityAuditAutomatedAccessBlockedTxt: Label 'The EU VAT Registration No. validation service (VIES) lookup was blocked because it was invoked from an automated (API) or background session.', Locked = true; VATRegistrationURL: Text; + local procedure BlockAutomatedSessionAccess() + var + EnvironmentInformation: Codeunit "Environment Information"; + AuditLog: Codeunit "Audit Log"; + begin + // The unauthenticated EU VIES service blocks the shared outbound IP address of a cloud app service when it + // receives high-volume automated validation, which then affects every co-located tenant on that address. + // Online (SaaS), reject automated (API/OData/SOAP) and background (non-interactive) sessions so a job queue + // or integration cannot repeatedly bulk-validate against VIES and get the shared address deny-listed. + // Interactive validation is unaffected. On-prem is not restricted because customers there own their own + // outbound address and only affect themselves. + if not EnvironmentInformation.IsSaaS() then + exit; + if IsInteractiveClientSession() then + exit; + + // 4, 0 = AuditMessageOperation / AuditMessageOperationResult (standard security-audit codes; also routes the entry to Purview). + AuditLog.LogAuditMessage(SecurityAuditAutomatedAccessBlockedTxt, SecurityOperationResult::Failure, AuditCategory::Authorization, 4, 0); + Session.LogMessage('0000VL4', AutomatedAccessBlockedMsg, Verbosity::Warning, DataClassification::SystemMetadata, TelemetryScope::All, 'Category', EUVATRegNoValidationServiceTok); + Error(AutomatedAccessBlockedErr); + end; + + local procedure IsInteractiveClientSession(): Boolean + var + ClientTypeManagement: Codeunit "Client Type Management"; + begin + if not GuiAllowed() then + exit(false); + exit(not (ClientTypeManagement.GetCurrentClientType() in [ClientType::Api, ClientType::SOAP, ClientType::OData, ClientType::ODataV4])); + end; + local procedure LookupVatRegistrationFromWebService(ShowErrors: Boolean) var TempBlobRequestBody: Codeunit "Temp Blob"; From c29fc73744a5616ed2a6bfdab4db61467617420a Mon Sep 17 00:00:00 2001 From: Djordje Cenic Date: Wed, 23 Sep 2026 13:02:10 +0200 Subject: [PATCH 2/7] [VIES Integration] Per-tenant daily request rate-limit Replace the background/API session block for codeunit 248 with a per-environment daily VIES lookup quota (table 243 "VAT Reg. No. Lookup Quota"), enforced on SaaS only. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../VATLookupExtDataHndl.Codeunit.al | 128 +++++++++++++++--- .../Registration/VATRegNoLookupQuota.Table.al | 50 +++++++ .../Tests/VAT/ERMVATVIESLookupUT.Codeunit.al | 92 +++++++++++++ 3 files changed, 248 insertions(+), 22 deletions(-) create mode 100644 src/Layers/W1/BaseApp/Finance/VAT/Registration/VATRegNoLookupQuota.Table.al diff --git a/src/Layers/W1/BaseApp/Finance/VAT/Registration/VATLookupExtDataHndl.Codeunit.al b/src/Layers/W1/BaseApp/Finance/VAT/Registration/VATLookupExtDataHndl.Codeunit.al index d7fa2a2a54e..5b3ee690c29 100644 --- a/src/Layers/W1/BaseApp/Finance/VAT/Registration/VATLookupExtDataHndl.Codeunit.al +++ b/src/Layers/W1/BaseApp/Finance/VAT/Registration/VATLookupExtDataHndl.Codeunit.al @@ -20,14 +20,15 @@ using System.Xml; /// codeunit 248 "VAT Lookup Ext. Data Hndl" { - Permissions = TableData "VAT Registration Log" = rimd; + Permissions = TableData "VAT Registration Log" = rimd, + TableData "VAT Reg. No. Lookup Quota" = rimd; TableNo = "VAT Registration Log"; trigger OnRun() var IsHandled: Boolean; begin - BlockAutomatedSessionAccess(); + RegisterAndCheckVIESCallQuota(); InitVATRegistrationLog(Rec); VATRegistrationLog := Rec; @@ -52,40 +53,123 @@ codeunit 248 "VAT Lookup Ext. Data Hndl" EUVATRegNoValidationServiceTok: Label 'EUVATRegNoValidationServiceTelemetryCategoryTok', Locked = true; ValidationSuccessfulMsg: Label 'The VAT reg. no. validation was successful', Locked = true; ValidationFailureMsg: Label 'The VAT reg. no. validation failed. Http request failure', Locked = true; - AutomatedAccessBlockedErr: Label 'VAT registration number validation against the EU VIES service is not available from API or background (non-interactive) sessions. Verify VAT registration numbers interactively instead.'; - AutomatedAccessBlockedMsg: Label 'The VAT reg. no. validation was blocked because it was invoked from an API or background session.', Locked = true; - SecurityAuditAutomatedAccessBlockedTxt: Label 'The EU VAT Registration No. validation service (VIES) lookup was blocked because it was invoked from an automated (API) or background session.', Locked = true; + DailyQuotaExceededErr: Label 'VAT registration number validation against the EU VIES service has reached the daily limit for this environment. Try again tomorrow, and avoid verifying VAT registration numbers in bulk.'; + DailyQuotaReachedMsg: Label 'The daily EU VAT reg. no. validation limit was reached for this environment.', Locked = true; + SecurityAuditDailyQuotaExceededTxt: Label 'An EU VAT Registration No. validation service (VIES) lookup was blocked because the environment reached its daily lookup limit.', Locked = true; VATRegistrationURL: Text; + QuotaTestOverride: Boolean; + QuotaTestMaxDailyCallCount: Integer; - local procedure BlockAutomatedSessionAccess() + local procedure RegisterAndCheckVIESCallQuota() var + VATRegNoLookupQuota: Record "VAT Reg. No. Lookup Quota"; EnvironmentInformation: Codeunit "Environment Information"; AuditLog: Codeunit "Audit Log"; begin - // The unauthenticated EU VIES service blocks the shared outbound IP address of a cloud app service when it - // receives high-volume automated validation, which then affects every co-located tenant on that address. - // Online (SaaS), reject automated (API/OData/SOAP) and background (non-interactive) sessions so a job queue - // or integration cannot repeatedly bulk-validate against VIES and get the shared address deny-listed. - // Interactive validation is unaffected. On-prem is not restricted because customers there own their own - // outbound address and only affect themselves. + // The unauthenticated EU VIES service deny-lists the shared outbound IP address of a cloud app service + // when it receives high-volume validation, which then affects every co-located environment on that address. + // Cap the number of VIES lookups per environment per day so a single environment cannot flood VIES - from + // any session type (interactive, background or API) and from either the Base Application or a per-tenant + // extension that reuses this codeunit - and get the shared address deny-listed. The count is kept in a + // single row shared by all companies in the database (DataPerCompany = false) that is locked for the brief + // read-modify-write, so concurrent sessions increment it atomically without lost updates. Enforced online + // (SaaS) only; on-prem environments own their own outbound address and only affect themselves. if not EnvironmentInformation.IsSaaS() then exit; - if IsInteractiveClientSession() then + + GetVIESCallQuotaUnderLock(VATRegNoLookupQuota); + + // Reset the counter at the start of a new (UTC) day. + if VATRegNoLookupQuota."Window Date" <> Today() then begin + VATRegNoLookupQuota."Window Date" := Today(); + VATRegNoLookupQuota."Daily Call Count" := 0; + end; + + // Block once the daily limit is reached. Blocked calls are not counted (they never reach the service). + if VATRegNoLookupQuota."Daily Call Count" >= GetMaxDailyCallCount() then + Error(DailyQuotaExceededErr); + + VATRegNoLookupQuota."Daily Call Count" += 1; + + // On the call that reaches the limit, record it once - after this, lookups are blocked for the rest of the day. + if VATRegNoLookupQuota."Daily Call Count" = GetMaxDailyCallCount() then begin + // 4, 0 = AuditMessageOperation / AuditMessageOperationResult (standard security-audit codes; also routes the entry to Purview). + AuditLog.LogAuditMessage(SecurityAuditDailyQuotaExceededTxt, SecurityOperationResult::Failure, AuditCategory::Authorization, 4, 0); + Session.LogMessage('0000VL7', DailyQuotaReachedMsg, Verbosity::Warning, DataClassification::SystemMetadata, TelemetryScope::All, 'Category', EUVATRegNoValidationServiceTok); + end; + + // Persist and commit the count before the outbound request: the increment stays durable regardless of the + // (isolated) caller transaction outcome, and the row lock is released before the potentially slow VIES call. + VATRegNoLookupQuota.Modify(); + Commit(); + end; + + local procedure GetVIESCallQuotaUnderLock(var VATRegNoLookupQuota: Record "VAT Reg. No. Lookup Quota") + begin + VATRegNoLookupQuota.LockTable(); + if VATRegNoLookupQuota.Get() then exit; + // Create the single row on first use. Do not rely on install/upgrade triggers - they are not guaranteed + // to have run for every environment. + VATRegNoLookupQuota.Init(); + VATRegNoLookupQuota."Primary Key" := ''; + VATRegNoLookupQuota.Insert(); + end; + + local procedure GetMaxDailyCallCount(): Integer + begin + if QuotaTestOverride then + exit(QuotaTestMaxDailyCallCount); + // Legitimate use is < ~200 lookups per environment per day (99th percentile). 2000 leaves generous headroom + // while staying roughly 10x below the daily volume at which VIES deny-lists a shared outbound address. + exit(2000); + end; + + // The following members exist only so the automated tests can exercise the daily-quota decision logic + // without calling the external VIES service. They are internal, so the Base Application test libraries can + // reach them but per-tenant extensions cannot influence or bypass the quota. + internal procedure SetVIESCallQuotaLimitForTest(MaxDailyCallCount: Integer) + begin + QuotaTestOverride := true; + QuotaTestMaxDailyCallCount := MaxDailyCallCount; + end; + + internal procedure InvokeVIESCallQuotaForTest() + begin + RegisterAndCheckVIESCallQuota(); + end; - // 4, 0 = AuditMessageOperation / AuditMessageOperationResult (standard security-audit codes; also routes the entry to Purview). - AuditLog.LogAuditMessage(SecurityAuditAutomatedAccessBlockedTxt, SecurityOperationResult::Failure, AuditCategory::Authorization, 4, 0); - Session.LogMessage('0000VL4', AutomatedAccessBlockedMsg, Verbosity::Warning, DataClassification::SystemMetadata, TelemetryScope::All, 'Category', EUVATRegNoValidationServiceTok); - Error(AutomatedAccessBlockedErr); + internal procedure SeedVIESCallQuotaForTest(WindowDate: Date; CallCount: Integer) + var + VATRegNoLookupQuota: Record "VAT Reg. No. Lookup Quota"; + begin + if not VATRegNoLookupQuota.Get() then begin + VATRegNoLookupQuota.Init(); + VATRegNoLookupQuota."Primary Key" := ''; + VATRegNoLookupQuota.Insert(); + end; + VATRegNoLookupQuota."Window Date" := WindowDate; + VATRegNoLookupQuota."Daily Call Count" := CallCount; + VATRegNoLookupQuota.Modify(); + end; + + internal procedure GetVIESCallCountForTest(): Integer + var + VATRegNoLookupQuota: Record "VAT Reg. No. Lookup Quota"; + begin + if not VATRegNoLookupQuota.Get() then + exit(0); + if VATRegNoLookupQuota."Window Date" <> Today() then + exit(0); + exit(VATRegNoLookupQuota."Daily Call Count"); end; - local procedure IsInteractiveClientSession(): Boolean + internal procedure ClearVIESCallQuotaForTest() var - ClientTypeManagement: Codeunit "Client Type Management"; + VATRegNoLookupQuota: Record "VAT Reg. No. Lookup Quota"; begin - if not GuiAllowed() then - exit(false); - exit(not (ClientTypeManagement.GetCurrentClientType() in [ClientType::Api, ClientType::SOAP, ClientType::OData, ClientType::ODataV4])); + if VATRegNoLookupQuota.Get() then + VATRegNoLookupQuota.Delete(); end; local procedure LookupVatRegistrationFromWebService(ShowErrors: Boolean) diff --git a/src/Layers/W1/BaseApp/Finance/VAT/Registration/VATRegNoLookupQuota.Table.al b/src/Layers/W1/BaseApp/Finance/VAT/Registration/VATRegNoLookupQuota.Table.al new file mode 100644 index 00000000000..32a4a3cd644 --- /dev/null +++ b/src/Layers/W1/BaseApp/Finance/VAT/Registration/VATRegNoLookupQuota.Table.al @@ -0,0 +1,50 @@ +// ------------------------------------------------------------------------------------------------ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. See License.txt in the project root for license information. +// ------------------------------------------------------------------------------------------------ +namespace Microsoft.Finance.VAT.Registration; + +/// +/// Per-environment counter that tracks the number of EU VIES VAT registration number lookups performed per day. +/// Used to cap the daily lookup volume per environment (all companies in the database share one counter) so a +/// single environment cannot flood the shared, unauthenticated VIES service and get the shared outbound IP address +/// deny-listed. Holds a single row that is locked for the brief read-modify-write, so concurrent sessions +/// increment it atomically. +/// +table 243 "VAT Reg. No. Lookup Quota" +{ + Access = Internal; + DataPerCompany = false; + DataClassification = SystemMetadata; + ReplicateData = false; + InherentEntitlements = RIMDX; + InherentPermissions = RIMDX; + + fields + { + field(1; "Primary Key"; Code[10]) + { + Caption = 'Primary Key'; + DataClassification = SystemMetadata; + } + field(2; "Window Date"; Date) + { + Caption = 'Window Date'; + DataClassification = SystemMetadata; + } + field(3; "Daily Call Count"; Integer) + { + Caption = 'Daily Call Count'; + DataClassification = SystemMetadata; + MinValue = 0; + } + } + + keys + { + key(PK; "Primary Key") + { + Clustered = true; + } + } +} diff --git a/src/Layers/W1/Tests/VAT/ERMVATVIESLookupUT.Codeunit.al b/src/Layers/W1/Tests/VAT/ERMVATVIESLookupUT.Codeunit.al index 9ec5dc75775..a2e56725cef 100644 --- a/src/Layers/W1/Tests/VAT/ERMVATVIESLookupUT.Codeunit.al +++ b/src/Layers/W1/Tests/VAT/ERMVATVIESLookupUT.Codeunit.al @@ -33,6 +33,98 @@ codeunit 134193 "ERM VAT VIES Lookup UT" Address2Txt: Label 'Address2', Locked = true; WrongLogEntryOnPageErr: Label 'Unexpected entry in VAT Registration Log page.'; + [Test] + procedure DailyVIESCallQuotaBlocksWhenLimitReached() + var + VATLookupExtDataHndl: Codeunit "VAT Lookup Ext. Data Hndl"; + EnvironmentInfoTestLibrary: Codeunit "Environment Info Test Library"; + Index: Integer; + begin + // [FEATURE] [VIES] [Throttling] + // [SCENARIO] When the daily VIES lookup quota is enforced, lookups beyond the daily limit are blocked. + Initialize(); + + // [GIVEN] An online (SaaS) environment where the daily VIES lookup quota is enforced at 3 lookups/day + EnvironmentInfoTestLibrary.SetTestabilitySoftwareAsAService(true); + VATLookupExtDataHndl.ClearVIESCallQuotaForTest(); + VATLookupExtDataHndl.SetVIESCallQuotaLimitForTest(3); + + // [WHEN] The daily limit of lookups is registered + for Index := 1 to 3 do + VATLookupExtDataHndl.InvokeVIESCallQuotaForTest(); + + // [THEN] The counter is at the limit + Assert.AreEqual(3, VATLookupExtDataHndl.GetVIESCallCountForTest(), 'The lookup counter should be at the daily limit.'); + + // [WHEN] One more lookup is attempted [THEN] it is blocked + asserterror VATLookupExtDataHndl.InvokeVIESCallQuotaForTest(); + Assert.ExpectedError('reached the daily limit'); + + // [THEN] Blocked lookups are not counted (they never reach the service) + Assert.AreEqual(3, VATLookupExtDataHndl.GetVIESCallCountForTest(), 'Blocked lookups should not increment the counter.'); + + VATLookupExtDataHndl.ClearVIESCallQuotaForTest(); + EnvironmentInfoTestLibrary.SetTestabilitySoftwareAsAService(false); + end; + + [Test] + procedure DailyVIESCallQuotaResetsOnNewDay() + var + VATLookupExtDataHndl: Codeunit "VAT Lookup Ext. Data Hndl"; + EnvironmentInfoTestLibrary: Codeunit "Environment Info Test Library"; + Index: Integer; + begin + // [FEATURE] [VIES] [Throttling] + // [SCENARIO] After the day rolls over the counter resets, so the customer can validate VAT numbers again + // up to a fresh daily limit. + Initialize(); + + // [GIVEN] An online (SaaS) environment with the quota enforced at 3 lookups/day + EnvironmentInfoTestLibrary.SetTestabilitySoftwareAsAService(true); + VATLookupExtDataHndl.SetVIESCallQuotaLimitForTest(3); + // [GIVEN] Yesterday already reached the daily limit + VATLookupExtDataHndl.SeedVIESCallQuotaForTest(Today() - 1, 3); + + // [WHEN] The customer makes lookups today up to the daily limit + for Index := 1 to 3 do + VATLookupExtDataHndl.InvokeVIESCallQuotaForTest(); + + // [THEN] None are blocked and today's lookups are counted from zero (yesterday's count was discarded) + Assert.AreEqual(3, VATLookupExtDataHndl.GetVIESCallCountForTest(), 'The counter should reset and count today''s lookups from zero.'); + + // [THEN] The daily limit still applies for the rest of the same day + asserterror VATLookupExtDataHndl.InvokeVIESCallQuotaForTest(); + Assert.ExpectedError('reached the daily limit'); + + VATLookupExtDataHndl.ClearVIESCallQuotaForTest(); + EnvironmentInfoTestLibrary.SetTestabilitySoftwareAsAService(false); + end; + + [Test] + procedure DailyVIESCallQuotaSkippedOnPrem() + var + VATLookupExtDataHndl: Codeunit "VAT Lookup Ext. Data Hndl"; + EnvironmentInfoTestLibrary: Codeunit "Environment Info Test Library"; + begin + // [FEATURE] [VIES] [Throttling] + // [SCENARIO] The daily quota applies to online environments only; on-premises lookups are never capped. + Initialize(); + + // [GIVEN] An on-premises environment with an (irrelevant) enforced limit of 1 lookup/day + EnvironmentInfoTestLibrary.SetTestabilitySoftwareAsAService(false); + VATLookupExtDataHndl.ClearVIESCallQuotaForTest(); + VATLookupExtDataHndl.SetVIESCallQuotaLimitForTest(1); + + // [WHEN] Several lookups are registered + VATLookupExtDataHndl.InvokeVIESCallQuotaForTest(); + VATLookupExtDataHndl.InvokeVIESCallQuotaForTest(); + + // [THEN] Nothing is counted or blocked because the quota does not apply on-premises + Assert.AreEqual(0, VATLookupExtDataHndl.GetVIESCallCountForTest(), 'The quota must not apply on-premises.'); + + VATLookupExtDataHndl.ClearVIESCallQuotaForTest(); + end; + [Test] procedure CheckInitDefaultTemplate() var From 7f5521451411f95a60b87ea09565f607c68589ee Mon Sep 17 00:00:00 2001 From: Djordje Cenic Date: Wed, 23 Sep 2026 14:46:24 +0200 Subject: [PATCH 3/7] [VIES Integration] Grant Tests-VAT access to Base Application internals Add Tests-VAT to BaseApp internalsVisibleTo so codeunit 134193 can reach the internal VIES quota test helpers on codeunit 248 (fixes AL0161 in the backport build). Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- src/Layers/W1/BaseApp/app.json | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/src/Layers/W1/BaseApp/app.json b/src/Layers/W1/BaseApp/app.json index b7b8a59642f..18ee7e915c1 100644 --- a/src/Layers/W1/BaseApp/app.json +++ b/src/Layers/W1/BaseApp/app.json @@ -26,6 +26,11 @@ } ], "internalsVisibleTo": [ + { + "id": "0f0955b8-92e2-4ce2-a580-3c4583dde9ae", + "name": "Tests-VAT", + "publisher": "Microsoft" + }, { "id": "6992416f-3f39-4d3c-8242-3fff61350bea", "name": "Business Central Cloud Migration - Previous Release", From 909c893d9deef6a999cc4d887b0597394d0c5be4 Mon Sep 17 00:00:00 2001 From: Djordje Cenic Date: Wed, 23 Sep 2026 16:20:56 +0200 Subject: [PATCH 4/7] [VIES Integration] Move VIES quota to a dedicated codeunit on the standard request path Backport of the review fixes: charge the per-environment daily VIES quota only on the standard request path (after the blank-number check and OnRun IsHandled event), and move the quota read-modify-write and its Commit into dedicated codeunit 247 "VAT Lookup Quota Mgt.". Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../VATLookupExtDataHndl.Codeunit.al | 130 +--------------- .../VATLookupQuotaMgt.Codeunit.al | 144 ++++++++++++++++++ .../Tests/VAT/ERMVATVIESLookupUT.Codeunit.al | 44 +++--- 3 files changed, 173 insertions(+), 145 deletions(-) create mode 100644 src/Layers/W1/BaseApp/Finance/VAT/Registration/VATLookupQuotaMgt.Codeunit.al diff --git a/src/Layers/W1/BaseApp/Finance/VAT/Registration/VATLookupExtDataHndl.Codeunit.al b/src/Layers/W1/BaseApp/Finance/VAT/Registration/VATLookupExtDataHndl.Codeunit.al index 5b3ee690c29..a033fa34d6f 100644 --- a/src/Layers/W1/BaseApp/Finance/VAT/Registration/VATLookupExtDataHndl.Codeunit.al +++ b/src/Layers/W1/BaseApp/Finance/VAT/Registration/VATLookupExtDataHndl.Codeunit.al @@ -7,10 +7,8 @@ namespace Microsoft.Finance.VAT.Registration; using Microsoft.CRM.Contact; using Microsoft.Sales.Customer; using System; -using System.Environment; using System.Integration; using System.Reflection; -using System.Telemetry; using System.Utilities; using System.Xml; @@ -20,16 +18,13 @@ using System.Xml; /// codeunit 248 "VAT Lookup Ext. Data Hndl" { - Permissions = TableData "VAT Registration Log" = rimd, - TableData "VAT Reg. No. Lookup Quota" = rimd; + Permissions = TableData "VAT Registration Log" = rimd; TableNo = "VAT Registration Log"; trigger OnRun() var IsHandled: Boolean; begin - RegisterAndCheckVIESCallQuota(); - InitVATRegistrationLog(Rec); VATRegistrationLog := Rec; @@ -53,124 +48,7 @@ codeunit 248 "VAT Lookup Ext. Data Hndl" EUVATRegNoValidationServiceTok: Label 'EUVATRegNoValidationServiceTelemetryCategoryTok', Locked = true; ValidationSuccessfulMsg: Label 'The VAT reg. no. validation was successful', Locked = true; ValidationFailureMsg: Label 'The VAT reg. no. validation failed. Http request failure', Locked = true; - DailyQuotaExceededErr: Label 'VAT registration number validation against the EU VIES service has reached the daily limit for this environment. Try again tomorrow, and avoid verifying VAT registration numbers in bulk.'; - DailyQuotaReachedMsg: Label 'The daily EU VAT reg. no. validation limit was reached for this environment.', Locked = true; - SecurityAuditDailyQuotaExceededTxt: Label 'An EU VAT Registration No. validation service (VIES) lookup was blocked because the environment reached its daily lookup limit.', Locked = true; VATRegistrationURL: Text; - QuotaTestOverride: Boolean; - QuotaTestMaxDailyCallCount: Integer; - - local procedure RegisterAndCheckVIESCallQuota() - var - VATRegNoLookupQuota: Record "VAT Reg. No. Lookup Quota"; - EnvironmentInformation: Codeunit "Environment Information"; - AuditLog: Codeunit "Audit Log"; - begin - // The unauthenticated EU VIES service deny-lists the shared outbound IP address of a cloud app service - // when it receives high-volume validation, which then affects every co-located environment on that address. - // Cap the number of VIES lookups per environment per day so a single environment cannot flood VIES - from - // any session type (interactive, background or API) and from either the Base Application or a per-tenant - // extension that reuses this codeunit - and get the shared address deny-listed. The count is kept in a - // single row shared by all companies in the database (DataPerCompany = false) that is locked for the brief - // read-modify-write, so concurrent sessions increment it atomically without lost updates. Enforced online - // (SaaS) only; on-prem environments own their own outbound address and only affect themselves. - if not EnvironmentInformation.IsSaaS() then - exit; - - GetVIESCallQuotaUnderLock(VATRegNoLookupQuota); - - // Reset the counter at the start of a new (UTC) day. - if VATRegNoLookupQuota."Window Date" <> Today() then begin - VATRegNoLookupQuota."Window Date" := Today(); - VATRegNoLookupQuota."Daily Call Count" := 0; - end; - - // Block once the daily limit is reached. Blocked calls are not counted (they never reach the service). - if VATRegNoLookupQuota."Daily Call Count" >= GetMaxDailyCallCount() then - Error(DailyQuotaExceededErr); - - VATRegNoLookupQuota."Daily Call Count" += 1; - - // On the call that reaches the limit, record it once - after this, lookups are blocked for the rest of the day. - if VATRegNoLookupQuota."Daily Call Count" = GetMaxDailyCallCount() then begin - // 4, 0 = AuditMessageOperation / AuditMessageOperationResult (standard security-audit codes; also routes the entry to Purview). - AuditLog.LogAuditMessage(SecurityAuditDailyQuotaExceededTxt, SecurityOperationResult::Failure, AuditCategory::Authorization, 4, 0); - Session.LogMessage('0000VL7', DailyQuotaReachedMsg, Verbosity::Warning, DataClassification::SystemMetadata, TelemetryScope::All, 'Category', EUVATRegNoValidationServiceTok); - end; - - // Persist and commit the count before the outbound request: the increment stays durable regardless of the - // (isolated) caller transaction outcome, and the row lock is released before the potentially slow VIES call. - VATRegNoLookupQuota.Modify(); - Commit(); - end; - - local procedure GetVIESCallQuotaUnderLock(var VATRegNoLookupQuota: Record "VAT Reg. No. Lookup Quota") - begin - VATRegNoLookupQuota.LockTable(); - if VATRegNoLookupQuota.Get() then - exit; - // Create the single row on first use. Do not rely on install/upgrade triggers - they are not guaranteed - // to have run for every environment. - VATRegNoLookupQuota.Init(); - VATRegNoLookupQuota."Primary Key" := ''; - VATRegNoLookupQuota.Insert(); - end; - - local procedure GetMaxDailyCallCount(): Integer - begin - if QuotaTestOverride then - exit(QuotaTestMaxDailyCallCount); - // Legitimate use is < ~200 lookups per environment per day (99th percentile). 2000 leaves generous headroom - // while staying roughly 10x below the daily volume at which VIES deny-lists a shared outbound address. - exit(2000); - end; - - // The following members exist only so the automated tests can exercise the daily-quota decision logic - // without calling the external VIES service. They are internal, so the Base Application test libraries can - // reach them but per-tenant extensions cannot influence or bypass the quota. - internal procedure SetVIESCallQuotaLimitForTest(MaxDailyCallCount: Integer) - begin - QuotaTestOverride := true; - QuotaTestMaxDailyCallCount := MaxDailyCallCount; - end; - - internal procedure InvokeVIESCallQuotaForTest() - begin - RegisterAndCheckVIESCallQuota(); - end; - - internal procedure SeedVIESCallQuotaForTest(WindowDate: Date; CallCount: Integer) - var - VATRegNoLookupQuota: Record "VAT Reg. No. Lookup Quota"; - begin - if not VATRegNoLookupQuota.Get() then begin - VATRegNoLookupQuota.Init(); - VATRegNoLookupQuota."Primary Key" := ''; - VATRegNoLookupQuota.Insert(); - end; - VATRegNoLookupQuota."Window Date" := WindowDate; - VATRegNoLookupQuota."Daily Call Count" := CallCount; - VATRegNoLookupQuota.Modify(); - end; - - internal procedure GetVIESCallCountForTest(): Integer - var - VATRegNoLookupQuota: Record "VAT Reg. No. Lookup Quota"; - begin - if not VATRegNoLookupQuota.Get() then - exit(0); - if VATRegNoLookupQuota."Window Date" <> Today() then - exit(0); - exit(VATRegNoLookupQuota."Daily Call Count"); - end; - - internal procedure ClearVIESCallQuotaForTest() - var - VATRegNoLookupQuota: Record "VAT Reg. No. Lookup Quota"; - begin - if VATRegNoLookupQuota.Get() then - VATRegNoLookupQuota.Delete(); - end; local procedure LookupVatRegistrationFromWebService(ShowErrors: Boolean) var @@ -191,6 +69,7 @@ codeunit 248 "VAT Lookup Ext. Data Hndl" var VATRegNoSrvConfig: Record "VAT Reg. No. Srv Config"; SOAPWebServiceRequestMgt: Codeunit "SOAP Web Service Request Mgt."; + VATLookupQuotaMgt: Codeunit "VAT Lookup Quota Mgt."; ResponseInStream: InStream; InStream: InStream; ResponseOutStream: OutStream; @@ -202,6 +81,11 @@ codeunit 248 "VAT Lookup Ext. Data Hndl" if VATRegistrationLog."VAT Registration No." = '' then Error(NoVATNoToValidateErr); + // Charge the per-environment daily VIES quota on the standard request path only - after the blank-number + // check and only when the lookup was not handled by a subscriber - so handled or invalid lookups that never + // contact VIES do not consume quota. Run as a dedicated codeunit so the counter commit is its own unit of work. + VATLookupQuotaMgt.Run(); + PrepareSOAPRequestBody(TempBlobBody); TempBlobBody.CreateInStream(InStream); diff --git a/src/Layers/W1/BaseApp/Finance/VAT/Registration/VATLookupQuotaMgt.Codeunit.al b/src/Layers/W1/BaseApp/Finance/VAT/Registration/VATLookupQuotaMgt.Codeunit.al new file mode 100644 index 00000000000..6aa9b7a1e3e --- /dev/null +++ b/src/Layers/W1/BaseApp/Finance/VAT/Registration/VATLookupQuotaMgt.Codeunit.al @@ -0,0 +1,144 @@ +// ------------------------------------------------------------------------------------------------ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. See License.txt in the project root for license information. +// ------------------------------------------------------------------------------------------------ +namespace Microsoft.Finance.VAT.Registration; + +using System.Environment; +using System.Telemetry; + +/// +/// Enforces the per-environment daily EU VIES lookup quota. Invoked as a dedicated codeunit run so the +/// counter read-modify-write and its commit form their own unit of work, keeping the increment durable +/// without committing the caller's transaction state as part of the quota logic. +/// +codeunit 247 "VAT Lookup Quota Mgt." +{ + Permissions = TableData "VAT Reg. No. Lookup Quota" = rimd; + + trigger OnRun() + begin + RegisterAndCheckQuota(); + end; + + var + DailyQuotaExceededErr: Label 'VAT registration number validation against the EU VIES service has reached the daily limit for this environment. Try again tomorrow, and avoid verifying VAT registration numbers in bulk.'; + DailyQuotaReachedMsg: Label 'The daily EU VAT reg. no. validation limit was reached for this environment.', Locked = true; + SecurityAuditDailyQuotaExceededTxt: Label 'An EU VAT Registration No. validation service (VIES) lookup was blocked because the environment reached its daily lookup limit.', Locked = true; + EUVATRegNoValidationServiceTok: Label 'EUVATRegNoValidationServiceTelemetryCategoryTok', Locked = true; + QuotaTestOverride: Boolean; + QuotaTestMaxDailyCallCount: Integer; + + local procedure RegisterAndCheckQuota() + var + VATRegNoLookupQuota: Record "VAT Reg. No. Lookup Quota"; + EnvironmentInformation: Codeunit "Environment Information"; + AuditLog: Codeunit "Audit Log"; + begin + // The unauthenticated EU VIES service deny-lists the shared outbound IP address of a cloud app service + // when it receives high-volume validation, which then affects every co-located environment on that address. + // Cap the number of VIES lookups per environment per day so a single environment cannot flood VIES - from + // any session type (interactive, background or API) and from either the Base Application or a per-tenant + // extension that reuses this codeunit - and get the shared address deny-listed. The count is kept in a + // single row shared by all companies in the database (DataPerCompany = false) that is locked for the brief + // read-modify-write, so concurrent sessions increment it atomically without lost updates. Enforced online + // (SaaS) only; on-prem environments own their own outbound address and only affect themselves. + if not EnvironmentInformation.IsSaaS() then + exit; + + GetQuotaUnderLock(VATRegNoLookupQuota); + + // Reset the counter at the start of a new (UTC) day. + if VATRegNoLookupQuota."Window Date" <> Today() then begin + VATRegNoLookupQuota."Window Date" := Today(); + VATRegNoLookupQuota."Daily Call Count" := 0; + end; + + // Block once the daily limit is reached. Blocked calls are not counted (they never reach the service). + if VATRegNoLookupQuota."Daily Call Count" >= GetMaxDailyCallCount() then + Error(DailyQuotaExceededErr); + + VATRegNoLookupQuota."Daily Call Count" += 1; + + // On the call that reaches the limit, record it once - after this, lookups are blocked for the rest of the day. + if VATRegNoLookupQuota."Daily Call Count" = GetMaxDailyCallCount() then begin + // 4, 0 = AuditMessageOperation / AuditMessageOperationResult (standard security-audit codes; also routes the entry to Purview). + AuditLog.LogAuditMessage(SecurityAuditDailyQuotaExceededTxt, SecurityOperationResult::Failure, AuditCategory::Authorization, 4, 0); + Session.LogMessage('0000VL7', DailyQuotaReachedMsg, Verbosity::Warning, DataClassification::SystemMetadata, TelemetryScope::All, 'Category', EUVATRegNoValidationServiceTok); + end; + + // Persist and commit the count in this dedicated run's unit of work before the outbound request: the + // increment stays durable even if the subsequent VIES call or the caller transaction later fails, and the + // row lock is released before the potentially slow VIES call. + VATRegNoLookupQuota.Modify(); + Commit(); + end; + + local procedure GetQuotaUnderLock(var VATRegNoLookupQuota: Record "VAT Reg. No. Lookup Quota") + begin + VATRegNoLookupQuota.LockTable(); + if VATRegNoLookupQuota.Get() then + exit; + // Create the single row on first use. Do not rely on install/upgrade triggers - they are not guaranteed + // to have run for every environment. + VATRegNoLookupQuota.Init(); + VATRegNoLookupQuota."Primary Key" := ''; + VATRegNoLookupQuota.Insert(); + end; + + local procedure GetMaxDailyCallCount(): Integer + begin + if QuotaTestOverride then + exit(QuotaTestMaxDailyCallCount); + // Legitimate use is < ~200 lookups per environment per day (99th percentile). 2000 leaves generous headroom + // while staying roughly 10x below the daily volume at which VIES deny-lists a shared outbound address. + exit(2000); + end; + + // The following members exist only so the automated tests can exercise the daily-quota decision logic + // without calling the external VIES service. They are internal, so the Base Application test libraries can + // reach them but per-tenant extensions cannot influence or bypass the quota. + internal procedure SetVIESCallQuotaLimitForTest(MaxDailyCallCount: Integer) + begin + QuotaTestOverride := true; + QuotaTestMaxDailyCallCount := MaxDailyCallCount; + end; + + internal procedure InvokeVIESCallQuotaForTest() + begin + RegisterAndCheckQuota(); + end; + + internal procedure SeedVIESCallQuotaForTest(WindowDate: Date; CallCount: Integer) + var + VATRegNoLookupQuota: Record "VAT Reg. No. Lookup Quota"; + begin + if not VATRegNoLookupQuota.Get() then begin + VATRegNoLookupQuota.Init(); + VATRegNoLookupQuota."Primary Key" := ''; + VATRegNoLookupQuota.Insert(); + end; + VATRegNoLookupQuota."Window Date" := WindowDate; + VATRegNoLookupQuota."Daily Call Count" := CallCount; + VATRegNoLookupQuota.Modify(); + end; + + internal procedure GetVIESCallCountForTest(): Integer + var + VATRegNoLookupQuota: Record "VAT Reg. No. Lookup Quota"; + begin + if not VATRegNoLookupQuota.Get() then + exit(0); + if VATRegNoLookupQuota."Window Date" <> Today() then + exit(0); + exit(VATRegNoLookupQuota."Daily Call Count"); + end; + + internal procedure ClearVIESCallQuotaForTest() + var + VATRegNoLookupQuota: Record "VAT Reg. No. Lookup Quota"; + begin + if VATRegNoLookupQuota.Get() then + VATRegNoLookupQuota.Delete(); + end; +} diff --git a/src/Layers/W1/Tests/VAT/ERMVATVIESLookupUT.Codeunit.al b/src/Layers/W1/Tests/VAT/ERMVATVIESLookupUT.Codeunit.al index a2e56725cef..c6e6be40eb0 100644 --- a/src/Layers/W1/Tests/VAT/ERMVATVIESLookupUT.Codeunit.al +++ b/src/Layers/W1/Tests/VAT/ERMVATVIESLookupUT.Codeunit.al @@ -36,7 +36,7 @@ codeunit 134193 "ERM VAT VIES Lookup UT" [Test] procedure DailyVIESCallQuotaBlocksWhenLimitReached() var - VATLookupExtDataHndl: Codeunit "VAT Lookup Ext. Data Hndl"; + VATLookupQuotaMgt: Codeunit "VAT Lookup Quota Mgt."; EnvironmentInfoTestLibrary: Codeunit "Environment Info Test Library"; Index: Integer; begin @@ -46,31 +46,31 @@ codeunit 134193 "ERM VAT VIES Lookup UT" // [GIVEN] An online (SaaS) environment where the daily VIES lookup quota is enforced at 3 lookups/day EnvironmentInfoTestLibrary.SetTestabilitySoftwareAsAService(true); - VATLookupExtDataHndl.ClearVIESCallQuotaForTest(); - VATLookupExtDataHndl.SetVIESCallQuotaLimitForTest(3); + VATLookupQuotaMgt.ClearVIESCallQuotaForTest(); + VATLookupQuotaMgt.SetVIESCallQuotaLimitForTest(3); // [WHEN] The daily limit of lookups is registered for Index := 1 to 3 do - VATLookupExtDataHndl.InvokeVIESCallQuotaForTest(); + VATLookupQuotaMgt.InvokeVIESCallQuotaForTest(); // [THEN] The counter is at the limit - Assert.AreEqual(3, VATLookupExtDataHndl.GetVIESCallCountForTest(), 'The lookup counter should be at the daily limit.'); + Assert.AreEqual(3, VATLookupQuotaMgt.GetVIESCallCountForTest(), 'The lookup counter should be at the daily limit.'); // [WHEN] One more lookup is attempted [THEN] it is blocked - asserterror VATLookupExtDataHndl.InvokeVIESCallQuotaForTest(); + asserterror VATLookupQuotaMgt.InvokeVIESCallQuotaForTest(); Assert.ExpectedError('reached the daily limit'); // [THEN] Blocked lookups are not counted (they never reach the service) - Assert.AreEqual(3, VATLookupExtDataHndl.GetVIESCallCountForTest(), 'Blocked lookups should not increment the counter.'); + Assert.AreEqual(3, VATLookupQuotaMgt.GetVIESCallCountForTest(), 'Blocked lookups should not increment the counter.'); - VATLookupExtDataHndl.ClearVIESCallQuotaForTest(); + VATLookupQuotaMgt.ClearVIESCallQuotaForTest(); EnvironmentInfoTestLibrary.SetTestabilitySoftwareAsAService(false); end; [Test] procedure DailyVIESCallQuotaResetsOnNewDay() var - VATLookupExtDataHndl: Codeunit "VAT Lookup Ext. Data Hndl"; + VATLookupQuotaMgt: Codeunit "VAT Lookup Quota Mgt."; EnvironmentInfoTestLibrary: Codeunit "Environment Info Test Library"; Index: Integer; begin @@ -81,29 +81,29 @@ codeunit 134193 "ERM VAT VIES Lookup UT" // [GIVEN] An online (SaaS) environment with the quota enforced at 3 lookups/day EnvironmentInfoTestLibrary.SetTestabilitySoftwareAsAService(true); - VATLookupExtDataHndl.SetVIESCallQuotaLimitForTest(3); + VATLookupQuotaMgt.SetVIESCallQuotaLimitForTest(3); // [GIVEN] Yesterday already reached the daily limit - VATLookupExtDataHndl.SeedVIESCallQuotaForTest(Today() - 1, 3); + VATLookupQuotaMgt.SeedVIESCallQuotaForTest(Today() - 1, 3); // [WHEN] The customer makes lookups today up to the daily limit for Index := 1 to 3 do - VATLookupExtDataHndl.InvokeVIESCallQuotaForTest(); + VATLookupQuotaMgt.InvokeVIESCallQuotaForTest(); // [THEN] None are blocked and today's lookups are counted from zero (yesterday's count was discarded) - Assert.AreEqual(3, VATLookupExtDataHndl.GetVIESCallCountForTest(), 'The counter should reset and count today''s lookups from zero.'); + Assert.AreEqual(3, VATLookupQuotaMgt.GetVIESCallCountForTest(), 'The counter should reset and count today''s lookups from zero.'); // [THEN] The daily limit still applies for the rest of the same day - asserterror VATLookupExtDataHndl.InvokeVIESCallQuotaForTest(); + asserterror VATLookupQuotaMgt.InvokeVIESCallQuotaForTest(); Assert.ExpectedError('reached the daily limit'); - VATLookupExtDataHndl.ClearVIESCallQuotaForTest(); + VATLookupQuotaMgt.ClearVIESCallQuotaForTest(); EnvironmentInfoTestLibrary.SetTestabilitySoftwareAsAService(false); end; [Test] procedure DailyVIESCallQuotaSkippedOnPrem() var - VATLookupExtDataHndl: Codeunit "VAT Lookup Ext. Data Hndl"; + VATLookupQuotaMgt: Codeunit "VAT Lookup Quota Mgt."; EnvironmentInfoTestLibrary: Codeunit "Environment Info Test Library"; begin // [FEATURE] [VIES] [Throttling] @@ -112,17 +112,17 @@ codeunit 134193 "ERM VAT VIES Lookup UT" // [GIVEN] An on-premises environment with an (irrelevant) enforced limit of 1 lookup/day EnvironmentInfoTestLibrary.SetTestabilitySoftwareAsAService(false); - VATLookupExtDataHndl.ClearVIESCallQuotaForTest(); - VATLookupExtDataHndl.SetVIESCallQuotaLimitForTest(1); + VATLookupQuotaMgt.ClearVIESCallQuotaForTest(); + VATLookupQuotaMgt.SetVIESCallQuotaLimitForTest(1); // [WHEN] Several lookups are registered - VATLookupExtDataHndl.InvokeVIESCallQuotaForTest(); - VATLookupExtDataHndl.InvokeVIESCallQuotaForTest(); + VATLookupQuotaMgt.InvokeVIESCallQuotaForTest(); + VATLookupQuotaMgt.InvokeVIESCallQuotaForTest(); // [THEN] Nothing is counted or blocked because the quota does not apply on-premises - Assert.AreEqual(0, VATLookupExtDataHndl.GetVIESCallCountForTest(), 'The quota must not apply on-premises.'); + Assert.AreEqual(0, VATLookupQuotaMgt.GetVIESCallCountForTest(), 'The quota must not apply on-premises.'); - VATLookupExtDataHndl.ClearVIESCallQuotaForTest(); + VATLookupQuotaMgt.ClearVIESCallQuotaForTest(); end; [Test] From 894ed4da74ced7ebcd565ad1d5b0bb09a1d160e3 Mon Sep 17 00:00:00 2001 From: Djordje Cenic Date: Wed, 23 Sep 2026 20:51:08 +0200 Subject: [PATCH 5/7] [VIES Integration] Reword VIES quota audit message to reflect limit reached, not blocked The audit entry fires on the last allowed lookup (the one that reaches the daily limit), so the message should describe the limit being reached rather than the lookup being blocked. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../Finance/VAT/Registration/VATLookupQuotaMgt.Codeunit.al | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Layers/W1/BaseApp/Finance/VAT/Registration/VATLookupQuotaMgt.Codeunit.al b/src/Layers/W1/BaseApp/Finance/VAT/Registration/VATLookupQuotaMgt.Codeunit.al index 6aa9b7a1e3e..50639906ab7 100644 --- a/src/Layers/W1/BaseApp/Finance/VAT/Registration/VATLookupQuotaMgt.Codeunit.al +++ b/src/Layers/W1/BaseApp/Finance/VAT/Registration/VATLookupQuotaMgt.Codeunit.al @@ -24,7 +24,7 @@ codeunit 247 "VAT Lookup Quota Mgt." var DailyQuotaExceededErr: Label 'VAT registration number validation against the EU VIES service has reached the daily limit for this environment. Try again tomorrow, and avoid verifying VAT registration numbers in bulk.'; DailyQuotaReachedMsg: Label 'The daily EU VAT reg. no. validation limit was reached for this environment.', Locked = true; - SecurityAuditDailyQuotaExceededTxt: Label 'An EU VAT Registration No. validation service (VIES) lookup was blocked because the environment reached its daily lookup limit.', Locked = true; + SecurityAuditDailyQuotaExceededTxt: Label 'The EU VAT Registration No. validation service (VIES) daily lookup limit was reached for this environment; further lookups are blocked for the rest of the day.', Locked = true; EUVATRegNoValidationServiceTok: Label 'EUVATRegNoValidationServiceTelemetryCategoryTok', Locked = true; QuotaTestOverride: Boolean; QuotaTestMaxDailyCallCount: Integer; From e591bbc85876918d3ad6b765b1f3561c5e2d7df9 Mon Sep 17 00:00:00 2001 From: Djordje Cenic Date: Wed, 23 Sep 2026 20:56:22 +0200 Subject: [PATCH 6/7] [VIES Integration] Make quota codeunit internal, fix comments, set test transaction model Address AL review agent feedback: - Mark codeunit 247 "VAT Lookup Quota Mgt." as Access = Internal (implementation detail). - Correct the doc/inline comments so they no longer imply the dedicated codeunit run isolates the transaction: the Commit also commits the caller's ambient transaction (the same boundary codeunit 248 already commits at around the outbound call). - Mark the quota tests with TransactionModel::AutoCommit since they commit the counter. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../Registration/VATLookupExtDataHndl.Codeunit.al | 3 ++- .../Registration/VATLookupQuotaMgt.Codeunit.al | 15 +++++++++------ .../W1/Tests/VAT/ERMVATVIESLookupUT.Codeunit.al | 3 +++ 3 files changed, 14 insertions(+), 7 deletions(-) diff --git a/src/Layers/W1/BaseApp/Finance/VAT/Registration/VATLookupExtDataHndl.Codeunit.al b/src/Layers/W1/BaseApp/Finance/VAT/Registration/VATLookupExtDataHndl.Codeunit.al index a033fa34d6f..934db5e1c9e 100644 --- a/src/Layers/W1/BaseApp/Finance/VAT/Registration/VATLookupExtDataHndl.Codeunit.al +++ b/src/Layers/W1/BaseApp/Finance/VAT/Registration/VATLookupExtDataHndl.Codeunit.al @@ -83,7 +83,8 @@ codeunit 248 "VAT Lookup Ext. Data Hndl" // Charge the per-environment daily VIES quota on the standard request path only - after the blank-number // check and only when the lookup was not handled by a subscriber - so handled or invalid lookups that never - // contact VIES do not consume quota. Run as a dedicated codeunit so the counter commit is its own unit of work. + // contact VIES do not consume quota. The dedicated codeunit commits the counter before the request; that + // commit also commits the ambient transaction, the same boundary this codeunit already commits at below. VATLookupQuotaMgt.Run(); PrepareSOAPRequestBody(TempBlobBody); diff --git a/src/Layers/W1/BaseApp/Finance/VAT/Registration/VATLookupQuotaMgt.Codeunit.al b/src/Layers/W1/BaseApp/Finance/VAT/Registration/VATLookupQuotaMgt.Codeunit.al index 50639906ab7..0a6a4f85840 100644 --- a/src/Layers/W1/BaseApp/Finance/VAT/Registration/VATLookupQuotaMgt.Codeunit.al +++ b/src/Layers/W1/BaseApp/Finance/VAT/Registration/VATLookupQuotaMgt.Codeunit.al @@ -8,12 +8,14 @@ using System.Environment; using System.Telemetry; /// -/// Enforces the per-environment daily EU VIES lookup quota. Invoked as a dedicated codeunit run so the -/// counter read-modify-write and its commit form their own unit of work, keeping the increment durable -/// without committing the caller's transaction state as part of the quota logic. +/// Enforces the per-environment daily EU VIES lookup quota, run as a dedicated codeunit on the standard +/// VIES request path. It increments and commits the daily counter before the outbound request so the count +/// stays durable. That commit also commits the caller's ambient transaction - the same boundary at which +/// codeunit 248 already commits around the outbound call - so it is not an isolated transaction. /// codeunit 247 "VAT Lookup Quota Mgt." { + Access = Internal; Permissions = TableData "VAT Reg. No. Lookup Quota" = rimd; trigger OnRun() @@ -67,9 +69,10 @@ codeunit 247 "VAT Lookup Quota Mgt." Session.LogMessage('0000VL7', DailyQuotaReachedMsg, Verbosity::Warning, DataClassification::SystemMetadata, TelemetryScope::All, 'Category', EUVATRegNoValidationServiceTok); end; - // Persist and commit the count in this dedicated run's unit of work before the outbound request: the - // increment stays durable even if the subsequent VIES call or the caller transaction later fails, and the - // row lock is released before the potentially slow VIES call. + // Persist and commit the count before the outbound request so the increment stays durable even if the + // subsequent VIES call fails, and the row lock is released before the potentially slow VIES call. This + // commit also commits the caller's ambient transaction - the same boundary codeunit 248 commits at around + // the outbound call - so it is not isolated from caller state. VATRegNoLookupQuota.Modify(); Commit(); end; diff --git a/src/Layers/W1/Tests/VAT/ERMVATVIESLookupUT.Codeunit.al b/src/Layers/W1/Tests/VAT/ERMVATVIESLookupUT.Codeunit.al index c6e6be40eb0..5df005d0c45 100644 --- a/src/Layers/W1/Tests/VAT/ERMVATVIESLookupUT.Codeunit.al +++ b/src/Layers/W1/Tests/VAT/ERMVATVIESLookupUT.Codeunit.al @@ -34,6 +34,7 @@ codeunit 134193 "ERM VAT VIES Lookup UT" WrongLogEntryOnPageErr: Label 'Unexpected entry in VAT Registration Log page.'; [Test] + [TransactionModel(TransactionModel::AutoCommit)] procedure DailyVIESCallQuotaBlocksWhenLimitReached() var VATLookupQuotaMgt: Codeunit "VAT Lookup Quota Mgt."; @@ -68,6 +69,7 @@ codeunit 134193 "ERM VAT VIES Lookup UT" end; [Test] + [TransactionModel(TransactionModel::AutoCommit)] procedure DailyVIESCallQuotaResetsOnNewDay() var VATLookupQuotaMgt: Codeunit "VAT Lookup Quota Mgt."; @@ -101,6 +103,7 @@ codeunit 134193 "ERM VAT VIES Lookup UT" end; [Test] + [TransactionModel(TransactionModel::AutoCommit)] procedure DailyVIESCallQuotaSkippedOnPrem() var VATLookupQuotaMgt: Codeunit "VAT Lookup Quota Mgt."; From e1c1a23c20ae10937417077d921ab3ad3582df06 Mon Sep 17 00:00:00 2001 From: Djordje Cenic Date: Wed, 23 Sep 2026 21:40:34 +0200 Subject: [PATCH 7/7] [VIES Integration] Rename quota telemetry label to Txt and reset quota state per test - Rename DailyQuotaReachedMsg -> DailyQuotaReachedTxt (telemetry/locked string convention). - Reset the VIES quota row and SaaS testability flag in the test Initialize() so an AutoCommit quota test that fails mid-way cannot leak committed state into later tests under a non-isolated test runner. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../Finance/VAT/Registration/VATLookupQuotaMgt.Codeunit.al | 4 ++-- src/Layers/W1/Tests/VAT/ERMVATVIESLookupUT.Codeunit.al | 7 +++++++ 2 files changed, 9 insertions(+), 2 deletions(-) diff --git a/src/Layers/W1/BaseApp/Finance/VAT/Registration/VATLookupQuotaMgt.Codeunit.al b/src/Layers/W1/BaseApp/Finance/VAT/Registration/VATLookupQuotaMgt.Codeunit.al index 0a6a4f85840..f1bffccb453 100644 --- a/src/Layers/W1/BaseApp/Finance/VAT/Registration/VATLookupQuotaMgt.Codeunit.al +++ b/src/Layers/W1/BaseApp/Finance/VAT/Registration/VATLookupQuotaMgt.Codeunit.al @@ -25,7 +25,7 @@ codeunit 247 "VAT Lookup Quota Mgt." var DailyQuotaExceededErr: Label 'VAT registration number validation against the EU VIES service has reached the daily limit for this environment. Try again tomorrow, and avoid verifying VAT registration numbers in bulk.'; - DailyQuotaReachedMsg: Label 'The daily EU VAT reg. no. validation limit was reached for this environment.', Locked = true; + DailyQuotaReachedTxt: Label 'The daily EU VAT reg. no. validation limit was reached for this environment.', Locked = true; SecurityAuditDailyQuotaExceededTxt: Label 'The EU VAT Registration No. validation service (VIES) daily lookup limit was reached for this environment; further lookups are blocked for the rest of the day.', Locked = true; EUVATRegNoValidationServiceTok: Label 'EUVATRegNoValidationServiceTelemetryCategoryTok', Locked = true; QuotaTestOverride: Boolean; @@ -66,7 +66,7 @@ codeunit 247 "VAT Lookup Quota Mgt." if VATRegNoLookupQuota."Daily Call Count" = GetMaxDailyCallCount() then begin // 4, 0 = AuditMessageOperation / AuditMessageOperationResult (standard security-audit codes; also routes the entry to Purview). AuditLog.LogAuditMessage(SecurityAuditDailyQuotaExceededTxt, SecurityOperationResult::Failure, AuditCategory::Authorization, 4, 0); - Session.LogMessage('0000VL7', DailyQuotaReachedMsg, Verbosity::Warning, DataClassification::SystemMetadata, TelemetryScope::All, 'Category', EUVATRegNoValidationServiceTok); + Session.LogMessage('0000VL7', DailyQuotaReachedTxt, Verbosity::Warning, DataClassification::SystemMetadata, TelemetryScope::All, 'Category', EUVATRegNoValidationServiceTok); end; // Persist and commit the count before the outbound request so the increment stays durable even if the diff --git a/src/Layers/W1/Tests/VAT/ERMVATVIESLookupUT.Codeunit.al b/src/Layers/W1/Tests/VAT/ERMVATVIESLookupUT.Codeunit.al index 5df005d0c45..6cb71d304b2 100644 --- a/src/Layers/W1/Tests/VAT/ERMVATVIESLookupUT.Codeunit.al +++ b/src/Layers/W1/Tests/VAT/ERMVATVIESLookupUT.Codeunit.al @@ -1120,11 +1120,18 @@ codeunit 134193 "ERM VAT VIES Lookup UT" var VATRegistrationLog: Record "VAT Registration Log"; VATRegistrationLogDetails: Record "VAT Registration Log Details"; + VATLookupQuotaMgt: Codeunit "VAT Lookup Quota Mgt."; + EnvironmentInfoTestLibrary: Codeunit "Environment Info Test Library"; begin ClearTemplates(); VATRegistrationLog.DeleteAll(); VATRegistrationLogDetails.DeleteAll(); LibraryVariableStorage.Clear(); + // Reset the per-environment VIES quota state at the start of every test so that an AutoCommit quota + // test which fails mid-way cannot leak committed state (the quota row or the SaaS testability flag) + // into later tests when run under a non-isolated test runner. + VATLookupQuotaMgt.ClearVIESCallQuotaForTest(); + EnvironmentInfoTestLibrary.SetTestabilitySoftwareAsAService(false); end; local procedure ClearTemplates()