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..934db5e1c9e 100644
--- a/src/Layers/W1/BaseApp/Finance/VAT/Registration/VATLookupExtDataHndl.Codeunit.al
+++ b/src/Layers/W1/BaseApp/Finance/VAT/Registration/VATLookupExtDataHndl.Codeunit.al
@@ -69,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;
@@ -80,6 +81,12 @@ 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. 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);
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..f1bffccb453
--- /dev/null
+++ b/src/Layers/W1/BaseApp/Finance/VAT/Registration/VATLookupQuotaMgt.Codeunit.al
@@ -0,0 +1,147 @@
+// ------------------------------------------------------------------------------------------------
+// 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, 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()
+ 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.';
+ 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;
+ 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', 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
+ // 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;
+
+ 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/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/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",
diff --git a/src/Layers/W1/Tests/VAT/ERMVATVIESLookupUT.Codeunit.al b/src/Layers/W1/Tests/VAT/ERMVATVIESLookupUT.Codeunit.al
index 9ec5dc75775..6cb71d304b2 100644
--- a/src/Layers/W1/Tests/VAT/ERMVATVIESLookupUT.Codeunit.al
+++ b/src/Layers/W1/Tests/VAT/ERMVATVIESLookupUT.Codeunit.al
@@ -33,6 +33,101 @@ codeunit 134193 "ERM VAT VIES Lookup UT"
Address2Txt: Label 'Address2', Locked = true;
WrongLogEntryOnPageErr: Label 'Unexpected entry in VAT Registration Log page.';
+ [Test]
+ [TransactionModel(TransactionModel::AutoCommit)]
+ procedure DailyVIESCallQuotaBlocksWhenLimitReached()
+ var
+ VATLookupQuotaMgt: Codeunit "VAT Lookup Quota Mgt.";
+ 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);
+ VATLookupQuotaMgt.ClearVIESCallQuotaForTest();
+ VATLookupQuotaMgt.SetVIESCallQuotaLimitForTest(3);
+
+ // [WHEN] The daily limit of lookups is registered
+ for Index := 1 to 3 do
+ VATLookupQuotaMgt.InvokeVIESCallQuotaForTest();
+
+ // [THEN] The counter is at the 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 VATLookupQuotaMgt.InvokeVIESCallQuotaForTest();
+ Assert.ExpectedError('reached the daily limit');
+
+ // [THEN] Blocked lookups are not counted (they never reach the service)
+ Assert.AreEqual(3, VATLookupQuotaMgt.GetVIESCallCountForTest(), 'Blocked lookups should not increment the counter.');
+
+ VATLookupQuotaMgt.ClearVIESCallQuotaForTest();
+ EnvironmentInfoTestLibrary.SetTestabilitySoftwareAsAService(false);
+ end;
+
+ [Test]
+ [TransactionModel(TransactionModel::AutoCommit)]
+ procedure DailyVIESCallQuotaResetsOnNewDay()
+ var
+ VATLookupQuotaMgt: Codeunit "VAT Lookup Quota Mgt.";
+ 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);
+ VATLookupQuotaMgt.SetVIESCallQuotaLimitForTest(3);
+ // [GIVEN] Yesterday already reached the daily limit
+ VATLookupQuotaMgt.SeedVIESCallQuotaForTest(Today() - 1, 3);
+
+ // [WHEN] The customer makes lookups today up to the daily limit
+ for Index := 1 to 3 do
+ VATLookupQuotaMgt.InvokeVIESCallQuotaForTest();
+
+ // [THEN] None are blocked and today's lookups are counted from zero (yesterday's count was discarded)
+ 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 VATLookupQuotaMgt.InvokeVIESCallQuotaForTest();
+ Assert.ExpectedError('reached the daily limit');
+
+ VATLookupQuotaMgt.ClearVIESCallQuotaForTest();
+ EnvironmentInfoTestLibrary.SetTestabilitySoftwareAsAService(false);
+ end;
+
+ [Test]
+ [TransactionModel(TransactionModel::AutoCommit)]
+ procedure DailyVIESCallQuotaSkippedOnPrem()
+ var
+ VATLookupQuotaMgt: Codeunit "VAT Lookup Quota Mgt.";
+ 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);
+ VATLookupQuotaMgt.ClearVIESCallQuotaForTest();
+ VATLookupQuotaMgt.SetVIESCallQuotaLimitForTest(1);
+
+ // [WHEN] Several lookups are registered
+ VATLookupQuotaMgt.InvokeVIESCallQuotaForTest();
+ VATLookupQuotaMgt.InvokeVIESCallQuotaForTest();
+
+ // [THEN] Nothing is counted or blocked because the quota does not apply on-premises
+ Assert.AreEqual(0, VATLookupQuotaMgt.GetVIESCallCountForTest(), 'The quota must not apply on-premises.');
+
+ VATLookupQuotaMgt.ClearVIESCallQuotaForTest();
+ end;
+
[Test]
procedure CheckInitDefaultTemplate()
var
@@ -1025,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()