[Bug][SubscriptionBilling] Enforce Subscription Line Start Date change rules on all edit paths - #10235
Conversation
…e 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 microsoft#9976 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Agentic PR Review - Round 1Recommendation: AcceptWhat this PR doesThis PR moves the Subscription Line start-date guard into the table field validation, so all edit paths use the same rule. It also changes the billed check from archived amount totals to record existence, so zero-value billing is still treated as billing. The fix matches the issue. The persisted Subscription Line is used to decide whether the old Next Billing Date was still on the old start date, which avoids using the new in-memory value too early. The checks run before the next billing date is recalculated, and the temporary-record escape keeps the contract renewal buffer path working. The added tests cover the missing page path, zero-value archive case, open billing line case, correction allowance, never-billed allowance, and temporary record path. SuggestionsNo suggestions. Risk assessment and necessityRisk: This changes validation for a financial data-integrity path. Existing imports, API calls, or page edits that moved the start date after billing will now get an error. That is intended. The new event is additive, the new archive key is index-only, and no public procedure signature is changed. Necessity: The change is needed because the old behavior could skip a billing period or bill the same period twice. The scope is right: it fixes the table-level rule, removes the duplicated page-only guard, and keeps the documented correction case open.
|
What & why
The Subscription Line start date could still be changed after the line had been billed. The next billing date silently followed the new start date, so an already invoiced period could be invoiced again, or a period could be skipped and never invoiced at all — usually noticed only after the invoices had gone out.
Two separate gaps caused this:
SubscriptionLine.UpdateServiceCommitment, which is called from the customer and vendor contract line subpages only. The same edit on the Subscription Lines page — or from code, the import, or the API — bypassed it entirely.ErrorIfBillingLineArchiveForServiceCommitmentExistsummed the archived billing amounts instead of asking whether billing had happened. A line billed at zero value (free or 100% discounted), or one whose invoices and credit memos netted to zero, was treated as never billed.The check now lives in the
OnValidatetrigger of"Subscription Line Start Date", ahead of the next-billing-date recalculation, so every edit path is covered by construction. It evaluates the two conditions from the issue: the change is allowed when noBilling Lineand noBilling Line Archiveexist for the"Entry No.", or when"Next Billing Date"is still on"Subscription Line Start Date"— the state a cancellation or credit memo leaves behind, which is exactly the correction case that has to stay open.Two implementation points a reviewer should know:
Rec. InsideOnValidatethe field already carries the new value, andxRecis not dependable on the contract line subpages either, because the page assigns the source expression beforeUpdateServiceCommitmentre-validates it. Reading the stored row is the only reliable source for "where was the next billing date before this edit".PostSubContractRenewal.InsertPlannedServiceCommitmentFromSalesServiceCommitmentbuffers an existing, already billed Subscription Line into a temporary record that carries its real"Entry No.", then validates the start date on it. Without the exemption, renewing any billed subscription would fail.OnAfterCheckSubscriptionLineStartDateChangeAllowedpasses the record by value, so a localization can tighten the rule by raising an error of its own. The guards themselves always run — anIsHandledbypass around an integrity check would let a subscriber switch off the very rule this change adds.The existence check on
Billing Line Archivenow runs on every start-date validation rather than only from the two subpages, so the PR adds the missingkey(SK2; "Subscription Line Entry No."). The same filter onBilling Linealready ridesSK7; the archive was the odd one out.Linked work
Fixes #9976
How I validated this
What I tested and the outcome
Built with
alc.exe(CodeCop + UICop +firstparty.ruleset.json) and published to a BC29 container over the dev endpoint. Both scenarios from the issue reproduce as bugs before the change and are blocked after it.TDD, red first. The three "must be rejected" tests were run against the unchanged app and failed for the right reasons — for scenario A,
PreventStartDateChangeOnSubscriptionLinesPageAfterBillingreported "An error was expected inside an ASSERTERROR statement", i.e. the page accepted the edit silently, which is the bug. All six pass after the change.New tests in
ServiceCommitmentTest.Codeunit.al(codeunit 148156):PreventStartDateChangeOnSubscriptionLinesPageAfterBillingTestPageUT_PreventStartDateChangeWhenBilledSubscriptionLineHasZeroAmountUT_PreventStartDateChangeWhenSubscriptionLineIsInCurrentBillingUT_AllowStartDateChangeWhenNextBillingDateIsOnStartDateUT_AllowStartDateChangeWhenSubscriptionLineHasNotBeenBilledUT_AllowStartDateChangeOnTemporarySubscriptionLineMutation testing, executed rather than reasoned. Each mutant was compiled, published and re-run: removing either existence check, comparing the in-memory start date instead of the persisted one, restoring
CalcSums(Amount) <> 0, and dropping theIsTemporary()exemption. All are killed. The temporary-record mutant survived the first pass, which is whyUT_AllowStartDateChangeOnTemporarySubscriptionLineexists — it was added specifically to kill it.Regression. Roughly 600 tests across the Subscription Billing suite pass, including the pre-existing guard tests
ExpectErrorOnModifyServiceStartDateWhenBillingLineArchiveExist(customer and vendor),UT_ExpectErrorOnModifyServiceStartDateWhenBillingLineExist, and bothTestChangeServiceStartDateAfterCorrection*tests that pin the correction case. Re-run against the final build after the added key: 148156 (36), 139686 (13), 139687 (88), 139688 (46), 148153 (74), 148154 (33), 148155 (69), 139916 (5).Five failures in
Service Object TestandContract Renewal Testare pre-existing: republishing the unchanged app reproduces all five identically. One is a missing localization table (12170) in the container.All 14 production
Validate("Subscription Line Start Date", …)call sites were traced. Every creation path zeroes"Entry No."before validating, so the guard exits; the import validates afterInsert(true)on a row where start date and next billing date are both0D, so it takes the allowance; renewal uses a temporary record. No path regresses.Risk & compatibility
Original Invoiced to Daterather than the start date — stays blocked. This is the documented intent;OnAfterCheckSubscriptionLineStartDateChangeAllowedis not a way back in, since it can only tighten. A localization needing the opposite would have to be discussed separately.Billing Line Archive. Index addition only — no field, no data change, no upgrade code.OnAfterCheckSubscriptionLineStartDateChangeAllowed. Its by-value parameter is deliberate and is a compatibility contract from here on: passing the record by value is what keeps subscribers from loosening the rule, and switching it tovarlater would be a breaking change.