From a0998b3885768abd2c8d3af42fd10a90c670f4cd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Miljan=20Milosavljevi=C4=87?= Date: Thu, 13 Aug 2026 16:17:01 +0200 Subject: [PATCH] [Bug][SubscriptionBilling] Enforce Subscription Line Start Date change rules on all edit paths The start date of a contract line could still be changed after the line had been billed, silently moving the next billing date with it and causing skipped or duplicated billing periods. Two gaps caused this: - The guard lived in UpdateServiceCommitment, which is only called from the customer and vendor contract line subpages. Editing the same field on the Subscription Lines page (or from code, import or the API) bypassed it. - Where the guard did run, it summed the archived billing amounts instead of asking whether billing had happened, so a line billed at zero value - or one whose invoices and credit memos netted to zero - passed the check. Move the check into the OnValidate trigger of "Subscription Line Start Date", before the next billing date is recalculated, so every edit path is covered, and evaluate the two documented conditions: the change is allowed when no Billing Line and no Billing Line Archive exist for the "Entry No.", or when the "Next Billing Date" is still on the "Subscription Line Start Date" - the state left behind by a cancellation or credit memo, which is exactly the correction case that must stay open. The allowance is evaluated against the persisted record rather than Rec, because inside OnValidate the field already carries the new value, and on the contract line subpages xRec is not reliable either - the page assigns the source expression before UpdateServiceCommitment re-validates it. Temporary records are exempt: contract renewal buffers an existing, already billed Subscription Line into a temporary record carrying its real "Entry No." and validates the start date on it. Add OnAfterCheckSubscriptionLineStartDateChangeAllowed, passing the record by value, so a localization can tighten the rule with an error of its own. The guards themselves always run - an IsHandled bypass around an integrity check would let a subscriber switch off the very rule this change introduces. The existence check on "Billing Line Archive" now runs on every start date validation instead of only from the two contract line subpages, so add the missing key on "Subscription Line Entry No.", mirroring key SK7 that the same filter on "Billing Line" already rides. Fixes #9976 Co-Authored-By: Claude Opus 5 (1M context) --- .../App/Billing/Tables/BillingLine.Table.al | 5 + .../Tables/BillingLineArchive.Table.al | 3 + .../Tables/SubscriptionLine.Table.al | 53 ++++- .../ServiceCommitmentTest.Codeunit.al | 193 ++++++++++++++++++ 4 files changed, 246 insertions(+), 8 deletions(-) diff --git a/src/Apps/W1/Subscription Billing/App/Billing/Tables/BillingLine.Table.al b/src/Apps/W1/Subscription Billing/App/Billing/Tables/BillingLine.Table.al index b2286f82d46..f7eb0ec8a68 100644 --- a/src/Apps/W1/Subscription Billing/App/Billing/Tables/BillingLine.Table.al +++ b/src/Apps/W1/Subscription Billing/App/Billing/Tables/BillingLine.Table.al @@ -470,6 +470,11 @@ table 8061 "Billing Line" Rec.SetRange("Subscription Contract Line No.", ContractLineNo); end; + internal procedure FilterBillingLineOnServiceCommitment(ServiceCommitmentEntryNo: Integer) + begin + Rec.SetRange("Subscription Line Entry No.", ServiceCommitmentEntryNo); + end; + local procedure RecalculateCustomerContractHarmonizedBillingFields() var CustomerContract: Record "Customer Subscription Contract"; diff --git a/src/Apps/W1/Subscription Billing/App/Billing/Tables/BillingLineArchive.Table.al b/src/Apps/W1/Subscription Billing/App/Billing/Tables/BillingLineArchive.Table.al index 81795965fbd..476623793d7 100644 --- a/src/Apps/W1/Subscription Billing/App/Billing/Tables/BillingLineArchive.Table.al +++ b/src/Apps/W1/Subscription Billing/App/Billing/Tables/BillingLineArchive.Table.al @@ -199,6 +199,9 @@ table 8064 "Billing Line Archive" key(SK1; "Subscription Contract No.", "Subscription Contract Line No.", "Billing from") { } + key(SK2; "Subscription Line Entry No.") + { + } } fieldgroups diff --git a/src/Apps/W1/Subscription Billing/App/Service Commitments/Tables/SubscriptionLine.Table.al b/src/Apps/W1/Subscription Billing/App/Service Commitments/Tables/SubscriptionLine.Table.al index 6973af99a6e..ed768f06751 100644 --- a/src/Apps/W1/Subscription Billing/App/Service Commitments/Tables/SubscriptionLine.Table.al +++ b/src/Apps/W1/Subscription Billing/App/Service Commitments/Tables/SubscriptionLine.Table.al @@ -56,6 +56,7 @@ table 8059 "Subscription Line" trigger OnValidate() begin + CheckSubscriptionLineStartDateChangeAllowed(); DateFormulaManagement.ErrorIfDateEmpty("Subscription Line Start Date", FieldCaption("Subscription Line Start Date")); UpdateNextBillingDate("Subscription Line Start Date" - 1); CheckServiceDates(); @@ -1022,11 +1023,7 @@ table 8059 "Subscription Line" FieldNo("Invoicing Item No."): Validate("Invoicing Item No.", "Invoicing Item No."); FieldNo("Subscription Line Start Date"): - begin - Rec.ErrorIfBillingLineArchiveForServiceCommitmentExist(); - Rec.ErrorIfBillingLineForServiceCommitmentExist(); - Validate("Subscription Line Start Date", "Subscription Line Start Date"); - end; + Validate("Subscription Line Start Date", "Subscription Line Start Date"); FieldNo("Subscription Line End Date"): Validate("Subscription Line End Date", "Subscription Line End Date"); FieldNo(Quantity): @@ -1475,11 +1472,42 @@ table 8059 "Subscription Line" end; internal procedure ErrorIfBillingLineForServiceCommitmentExist() + var + BillingLine: Record "Billing Line"; begin - if BillingLineExists() then + BillingLine.FilterBillingLineOnServiceCommitment(Rec."Entry No."); + if not BillingLine.IsEmpty() then Error(BillingLineForServiceCommitmentExistErr); end; + /// + /// Checks whether the Subscription Line Start Date may still be changed. + /// The change is allowed as long as no billing has taken place for the Subscription Line, or as long as the + /// Next Billing Date is still on the Subscription Line Start Date, which is the state after a cancellation + /// or a credit memo and the case in which the billing period legitimately has to be corrected. + /// + local procedure CheckSubscriptionLineStartDateChangeAllowed() + begin + CheckBillingStateAllowsSubscriptionLineStartDateChange(); + OnAfterCheckSubscriptionLineStartDateChangeAllowed(Rec); + end; + + local procedure CheckBillingStateAllowsSubscriptionLineStartDateChange() + var + SubscriptionLine: Record "Subscription Line"; + begin + if Rec.IsTemporary() then + exit; + SubscriptionLine.SetLoadFields("Subscription Line Start Date", "Next Billing Date"); + if not SubscriptionLine.Get(Rec."Entry No.") then + exit; + if SubscriptionLine."Next Billing Date" = SubscriptionLine."Subscription Line Start Date" then + exit; + + ErrorIfBillingLineArchiveForServiceCommitmentExist(); + ErrorIfBillingLineForServiceCommitmentExist(); + end; + internal procedure GetPartnerNoFromContract(): Code[20] var CustomerContract: Record "Customer Subscription Contract"; @@ -1504,8 +1532,7 @@ table 8059 "Subscription Line" BillingLineArchive: Record "Billing Line Archive"; begin BillingLineArchive.FilterBillingLineArchiveOnServiceCommitment(Rec."Entry No."); - BillingLineArchive.CalcSums(Amount); - if BillingLineArchive.Amount <> 0 then + if not BillingLineArchive.IsEmpty() then Error(BillingLineArchiveForServiceCommitmentExistErr); end; @@ -2111,6 +2138,16 @@ table 8059 "Subscription Line" begin end; + /// + /// Raised after the Subscription Line Start Date change has passed the billing state check. + /// Subscribers can only tighten the rule, by raising an error of their own. + /// + /// The Subscription Line carrying the new Subscription Line Start Date. + [IntegrationEvent(false, false)] + local procedure OnAfterCheckSubscriptionLineStartDateChangeAllowed(SubscriptionLine: Record "Subscription Line") + begin + end; + [IntegrationEvent(false, false)] local procedure OnAfterCalculateServiceAmount(var SubscriptionLine: Record "Subscription Line"; CalledByFieldNo: Integer) begin diff --git a/src/Apps/W1/Subscription Billing/Test/Service Commitments/ServiceCommitmentTest.Codeunit.al b/src/Apps/W1/Subscription Billing/Test/Service Commitments/ServiceCommitmentTest.Codeunit.al index 585a7cbcd77..3653fbb4f4b 100644 --- a/src/Apps/W1/Subscription Billing/Test/Service Commitments/ServiceCommitmentTest.Codeunit.al +++ b/src/Apps/W1/Subscription Billing/Test/Service Commitments/ServiceCommitmentTest.Codeunit.al @@ -29,6 +29,8 @@ codeunit 148156 "Service Commitment Test" DiscountCanBeInvoicedViaContractErr: Label 'Recurring discounts can only be granted for Invoicing via Contract.', Locked = true; DiscountCannotBeAssignedErr: Label 'Subscription Package Lines, which are discounts, can only be assigned to Subscription Items.', Locked = true; RecurringDiscountCannotBeGrantedErr: Label 'Recurring discounts cannot be granted in conjunction with Usage Based Billing', Locked = true; + BillingLineForServiceCommitmentExistErr: Label 'The contract line is in the current billing. Delete the billing line to be able to adjust the Subscription Line start date.', Locked = true; + BillingLineArchiveForServiceCommitmentExistErr: Label 'The contract line has already been billed. The Subscription Line start date can no longer be changed.', Locked = true; #region Tests @@ -597,6 +599,139 @@ codeunit 148156 "Service Commitment Test" Assert.ExpectedError(RecurringDiscountCannotBeGrantedErr); end; + [Test] + [HandlerFunctions('CreateCustomerBillingDocsPageHandler,ExchangeRateSelectionModalPageHandler,MessageHandler')] + procedure PreventStartDateChangeOnSubscriptionLinesPageAfterBilling() + var + BillingLine: Record "Billing Line"; + BillingTemplate: Record "Billing Template"; + SalesHeader: Record "Sales Header"; + SubscriptionLine: Record "Subscription Line"; + ServiceCommitmentsPage: TestPage "Service Commitments"; + begin + // [SCENARIO] The Subscription Line Start Date can no longer be changed on the Subscription Lines page after the Subscription Line has been billed + + // [GIVEN] A Customer Subscription Contract with a Subscription Line for which an invoice has been posted + Initialize(); + BillContractAndPostInvoice(BillingTemplate, BillingLine, SalesHeader); + SubscriptionLine.Get(BillingLine."Subscription Line Entry No."); + Assert.AreNotEqual(SubscriptionLine."Subscription Line Start Date", SubscriptionLine."Next Billing Date", 'The Subscription Line should have been billed.'); + Commit(); // retain data after asserterror + + // [WHEN] The Subscription Line Start Date is changed on the Subscription Lines page + ServiceCommitmentsPage.OpenEdit(); + ServiceCommitmentsPage.GoToRecord(SubscriptionLine); + + // [THEN] The change is rejected with the same error as on the contract line list + asserterror ServiceCommitmentsPage."Service Start Date".SetValue(GetDifferentDateAllowedByLicense(SubscriptionLine."Subscription Line Start Date")); + Assert.ExpectedError(BillingLineArchiveForServiceCommitmentExistErr); + end; + + [Test] + procedure UT_PreventStartDateChangeWhenBilledSubscriptionLineHasZeroAmount() + var + SubscriptionLine: Record "Subscription Line"; + begin + // [SCENARIO] The Subscription Line Start Date can no longer be changed for a Subscription Line which has been billed at zero value + + // [GIVEN] A billed Subscription Line whose archived billing lines add up to zero + Initialize(); + MockBilledSubscriptionLine(SubscriptionLine); + MockBillingLineArchive(SubscriptionLine."Entry No.", 0); + + // [WHEN] The Subscription Line Start Date is changed + // [THEN] The change is rejected + asserterror SubscriptionLine.Validate("Subscription Line Start Date", CalcDate('<+2M>', SubscriptionLine."Subscription Line Start Date")); + Assert.ExpectedError(BillingLineArchiveForServiceCommitmentExistErr); + end; + + [Test] + procedure UT_PreventStartDateChangeWhenSubscriptionLineIsInCurrentBilling() + var + SubscriptionLine: Record "Subscription Line"; + begin + // [SCENARIO] The Subscription Line Start Date cannot be changed as long as the Subscription Line is part of the current billing + + // [GIVEN] A Subscription Line with an open Billing Line + Initialize(); + MockBilledSubscriptionLine(SubscriptionLine); + MockBillingLine(SubscriptionLine."Entry No."); + + // [WHEN] The Subscription Line Start Date is changed + // [THEN] The change is rejected + asserterror SubscriptionLine.Validate("Subscription Line Start Date", CalcDate('<+2M>', SubscriptionLine."Subscription Line Start Date")); + Assert.ExpectedError(BillingLineForServiceCommitmentExistErr); + end; + + [Test] + procedure UT_AllowStartDateChangeWhenNextBillingDateIsOnStartDate() + var + SubscriptionLine: Record "Subscription Line"; + NewStartDate: Date; + begin + // [SCENARIO] The Subscription Line Start Date may be corrected as long as the Next Billing Date is back on the Subscription Line Start Date + + // [GIVEN] A billed Subscription Line whose Next Billing Date has been reset to the Subscription Line Start Date by a credit memo + Initialize(); + MockBilledSubscriptionLine(SubscriptionLine); + MockBillingLineArchive(SubscriptionLine."Entry No.", LibraryRandom.RandDec(100, 2)); + SubscriptionLine."Next Billing Date" := SubscriptionLine."Subscription Line Start Date"; + SubscriptionLine.Modify(false); + + // [WHEN] The Subscription Line Start Date is changed + NewStartDate := CalcDate('<+2M>', SubscriptionLine."Subscription Line Start Date"); + SubscriptionLine.Validate("Subscription Line Start Date", NewStartDate); + + // [THEN] The change is accepted and the Next Billing Date follows the new Subscription Line Start Date + SubscriptionLine.TestField("Subscription Line Start Date", NewStartDate); + SubscriptionLine.TestField("Next Billing Date", NewStartDate); + end; + + [Test] + procedure UT_AllowStartDateChangeWhenSubscriptionLineHasNotBeenBilled() + var + SubscriptionLine: Record "Subscription Line"; + NewStartDate: Date; + begin + // [SCENARIO] The Subscription Line Start Date may be changed as long as no billing has been performed for the Subscription Line + + // [GIVEN] A Subscription Line without any Billing Line and without any Billing Line Archive + Initialize(); + MockBilledSubscriptionLine(SubscriptionLine); + + // [WHEN] The Subscription Line Start Date is changed + NewStartDate := CalcDate('<+2M>', SubscriptionLine."Subscription Line Start Date"); + SubscriptionLine.Validate("Subscription Line Start Date", NewStartDate); + + // [THEN] The change is accepted and the Next Billing Date follows the new Subscription Line Start Date + SubscriptionLine.TestField("Subscription Line Start Date", NewStartDate); + SubscriptionLine.TestField("Next Billing Date", NewStartDate); + end; + + [Test] + procedure UT_AllowStartDateChangeOnTemporarySubscriptionLine() + var + SubscriptionLine: Record "Subscription Line"; + TempSubscriptionLine: Record "Subscription Line" temporary; + NewStartDate: Date; + begin + // [SCENARIO] Buffering a billed Subscription Line in a temporary record, as the contract renewal does, is not blocked by the start date check + + // [GIVEN] A billed Subscription Line and a temporary Subscription Line carrying its Entry No. + Initialize(); + MockBilledSubscriptionLine(SubscriptionLine); + MockBillingLineArchive(SubscriptionLine."Entry No.", LibraryRandom.RandDec(100, 2)); + TempSubscriptionLine.Init(); + TempSubscriptionLine."Entry No." := SubscriptionLine."Entry No."; + + // [WHEN] The Subscription Line Start Date is set on the temporary record + NewStartDate := CalcDate('<+2M>', SubscriptionLine."Subscription Line Start Date"); + TempSubscriptionLine.Validate("Subscription Line Start Date", NewStartDate); + + // [THEN] The value is accepted + TempSubscriptionLine.TestField("Subscription Line Start Date", NewStartDate); + end; + #endregion Tests #region Procedures @@ -637,6 +772,58 @@ codeunit 148156 "Service Commitment Test" until ServiceCommitment.Next() = 0; end; + local procedure BillContractAndPostInvoice(var BillingTemplate: Record "Billing Template"; var BillingLine: Record "Billing Line"; var SalesHeader: Record "Sales Header") + begin + ContractTestLibrary.CreateCustomerContractAndCreateContractLinesForItems(CustomerContract, ServiceObject, ''); + ContractTestLibrary.DisableDeferralsForCustomerContract(CustomerContract, false); + ContractTestLibrary.CreateBillingProposal(BillingTemplate, Enum::"Service Partner"::Customer); + BillingLine.SetRange("Billing Template Code", BillingTemplate.Code); + BillingLine.SetRange(Partner, BillingLine.Partner::Customer); + Codeunit.Run(Codeunit::"Create Billing Documents", BillingLine); + BillingLine.FindLast(); + SalesHeader.Get(SalesHeader."Document Type"::Invoice, BillingLine."Document No."); + LibrarySales.PostSalesDocument(SalesHeader, true, true); + end; + + local procedure GetDifferentDateAllowedByLicense(ReferenceDate: Date) NewDate: Date + begin + // the date filter of the demo license only allows dates in November, December, January and February + NewDate := DMY2Date(1, 2, Date2DMY(ReferenceDate, 3)); + if NewDate = ReferenceDate then + NewDate := DMY2Date(1, 1, Date2DMY(ReferenceDate, 3)); + end; + + local procedure MockBilledSubscriptionLine(var SubscriptionLine: Record "Subscription Line") + begin + SubscriptionLine.Init(); + SubscriptionLine."Entry No." := 0; + SubscriptionLine."Invoicing via" := SubscriptionLine."Invoicing via"::Contract; + SubscriptionLine."Subscription Line Start Date" := WorkDate(); + SubscriptionLine."Next Billing Date" := CalcDate('<+1M>', WorkDate()); + SubscriptionLine.Insert(false); + end; + + local procedure MockBillingLine(SubscriptionLineEntryNo: Integer) + var + BillingLine: Record "Billing Line"; + begin + BillingLine.Init(); + BillingLine."Entry No." := 0; + BillingLine."Subscription Line Entry No." := SubscriptionLineEntryNo; + BillingLine.Insert(false); + end; + + local procedure MockBillingLineArchive(SubscriptionLineEntryNo: Integer; ArchivedAmount: Decimal) + var + BillingLineArchive: Record "Billing Line Archive"; + begin + BillingLineArchive.Init(); + BillingLineArchive."Entry No." := 0; + BillingLineArchive."Subscription Line Entry No." := SubscriptionLineEntryNo; + BillingLineArchive.Amount := ArchivedAmount; + BillingLineArchive.Insert(false); + end; + local procedure MockSubscriptionLine(var SubscriptionLine: Record "Subscription Line") begin SubscriptionLine.Init(); @@ -681,6 +868,12 @@ codeunit 148156 "Service Commitment Test" Reply := true; end; + [ModalPageHandler] + procedure CreateCustomerBillingDocsPageHandler(var CreateCustomerBillingDocs: TestPage "Create Customer Billing Docs") + begin + CreateCustomerBillingDocs.OK().Invoke(); + end; + [ModalPageHandler] procedure ExchangeRateSelectionModalPageHandler(var ExchangeRateSelectionPage: TestPage "Exchange Rate Selection") begin