From 4852a660003b6dca12844ea50e884207d70b7032 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Miljan=20Milosavljevi=C4=87?= Date: Fri, 19 Jun 2026 00:33:49 +0200 Subject: [PATCH] [Bug][SubscriptionBilling]: Make contract billing line price VAT-inclusive when document prices include VAT When a Subscription Contract is billed for a customer/vendor whose prices include VAT, the contract invoice was created with the net (VAT-exclusive) amount placed onto a "Prices Including VAT" = YES document, so the platform treated that net value as gross and the resulting VAT base/amounts did not reconcile with the original order. Add a generic helper "ToVATInclusiveUnitPrice" that grosses up a net price by the line's VAT factor (rounded to the currency's Unit-Amount Rounding Precision), and apply it at every price-assignment site when the document has "Prices Including VAT": - Sales: standard line and usage-based path ("Unit Price") - Purchase: standard line and usage-based path ("Direct Unit Cost") Full VAT and zero-VAT lines are left untouched. The factor is determined before the value is validated, so each price is validated exactly once. Add tests covering the VAT-inclusive and prices-excluding-VAT cases for both the customer (sales) and vendor (purchase) contract billing flows. Co-Authored-By: Claude Opus 4.8 --- .../CreateBillingDocuments.Codeunit.al | 46 +++- .../RecurringBillingDocsTest.Codeunit.al | 243 ++++++++++++++++++ 2 files changed, 283 insertions(+), 6 deletions(-) diff --git a/src/Apps/W1/Subscription Billing/App/Billing/Codeunits/CreateBillingDocuments.Codeunit.al b/src/Apps/W1/Subscription Billing/App/Billing/Codeunits/CreateBillingDocuments.Codeunit.al index 95249159d05..d8ca5491967 100644 --- a/src/Apps/W1/Subscription Billing/App/Billing/Codeunits/CreateBillingDocuments.Codeunit.al +++ b/src/Apps/W1/Subscription Billing/App/Billing/Codeunits/CreateBillingDocuments.Codeunit.al @@ -1,5 +1,6 @@ namespace Microsoft.SubscriptionBilling; +using Microsoft.Finance.Currency; using Microsoft.Foundation.ExtendedText; using Microsoft.Inventory.Item; using Microsoft.Purchases.Document; @@ -247,6 +248,7 @@ codeunit 8060 "Create Billing Documents" SubContractsItemManagement: Codeunit "Sub. Contracts Item Management"; TransferExtendedText: Codeunit "Transfer Extended Text"; UsageBasedDocTypeConv: Codeunit "Usage Based Doc. Type Conv."; + UnitPrice: Decimal; BillingLineNo: Integer; begin ServiceObject.Get(TempBillingLine."Subscription Header No."); @@ -274,8 +276,11 @@ codeunit 8060 "Create Billing Documents" if SalesLine."Unit of Measure Code" <> ServiceObject."Unit of Measure" then SalesLine.Validate("Unit of Measure Code", ServiceObject."Unit of Measure"); SalesLine.Validate(Quantity, TempBillingLine.GetSign() * ServiceObject.Quantity); + UnitPrice := SalesLine.GetSalesDocumentSign() * TempBillingLine."Unit Price"; + if SalesHeader."Prices Including VAT" then + UnitPrice := ToVATInclusiveUnitPrice(UnitPrice, SalesLine."VAT %", SalesLine."VAT Calculation Type" = SalesLine."VAT Calculation Type"::"Full VAT", SalesLine."Currency Code"); // Unit Price is assigned directly; the following Validate("Line Discount %") recalculates the line amount. - SalesLine."Unit Price" := SalesLine.GetSalesDocumentSign() * TempBillingLine."Unit Price"; + SalesLine."Unit Price" := UnitPrice; SalesLine.Validate("Line Discount %", TempBillingLine."Discount %"); SalesLine.Validate("Unit Cost (LCY)", TempBillingLine."Unit Cost (LCY)"); SalesLine."Recurring Billing from" := TempBillingLine."Billing from"; @@ -334,10 +339,28 @@ codeunit 8060 "Create Billing Documents" OnAfterInsertSalesLineFromBillingLine(CustomerContractLine, SalesLine); end; + local procedure ToVATInclusiveUnitPrice(NetUnitPrice: Decimal; VATPercent: Decimal; IsFullVAT: Boolean; CurrencyCode: Code[10]): Decimal + var + Currency: Record Currency; + VATFactor: Decimal; + begin + if IsFullVAT then + exit(NetUnitPrice); + VATFactor := 1 + VATPercent / 100; + if VATFactor = 0 then + exit(NetUnitPrice); + if CurrencyCode = '' then + Currency.InitRoundingPrecision() + else + Currency.Get(CurrencyCode); + exit(Round(NetUnitPrice * VATFactor, Currency."Unit-Amount Rounding Precision")); + end; + local procedure SetInvoicePriceFromUsageDataBilling(var SalesLine: Record "Sales Line"; var BillingLine: Record "Billing Line") var UsageDataBilling: Record "Usage Data Billing"; ServiceCommitment: Record "Subscription Line"; + UnitPrice: Decimal; begin if not ServiceCommitment.Get(BillingLine."Subscription Line Entry No.") then exit; @@ -359,9 +382,12 @@ codeunit 8060 "Create Billing Documents" UsageDataBilling.SetRange(Quantity); UsageDataBilling.CalcSums(Amount); if SalesLine.Quantity <> 0 then - SalesLine.Validate("Unit Price", SalesLine.GetSalesDocumentSign() * UsageDataBilling.Amount / SalesLine.Quantity) + UnitPrice := SalesLine.GetSalesDocumentSign() * UsageDataBilling.Amount / SalesLine.Quantity else - SalesLine.Validate("Unit Price", UsageDataBilling."Unit Price"); + UnitPrice := UsageDataBilling."Unit Price"; + if SalesHeader."Prices Including VAT" then + UnitPrice := ToVATInclusiveUnitPrice(UnitPrice, SalesLine."VAT %", SalesLine."VAT Calculation Type" = SalesLine."VAT Calculation Type"::"Full VAT", SalesLine."Currency Code"); + SalesLine.Validate("Unit Price", UnitPrice); SalesLine.Validate("Line Discount %", ServiceCommitment."Discount %"); end; @@ -375,6 +401,7 @@ codeunit 8060 "Create Billing Documents" UsageBasedDocTypeConv: Codeunit "Usage Based Doc. Type Conv."; SubContractsItemManagement: Codeunit "Sub. Contracts Item Management"; TransferExtendedText: Codeunit "Transfer Extended Text"; + DirectUnitCost: Decimal; BillingLineNo: Integer; begin ServiceObject.Get(TempBillingLine."Subscription Header No."); @@ -397,8 +424,11 @@ codeunit 8060 "Create Billing Documents" if PurchaseLine."Unit of Measure Code" <> ServiceObject."Unit of Measure" then PurchaseLine.Validate("Unit of Measure Code", ServiceObject."Unit of Measure"); PurchaseLine.Validate(Quantity, TempBillingLine.GetSign() * ServiceObject.Quantity); + DirectUnitCost := PurchaseLine.GetPurchaseDocumentSign() * TempBillingLine."Unit Price"; + if PurchaseHeader."Prices Including VAT" then + DirectUnitCost := ToVATInclusiveUnitPrice(DirectUnitCost, PurchaseLine."VAT %", PurchaseLine."VAT Calculation Type" = PurchaseLine."VAT Calculation Type"::"Full VAT", PurchaseLine."Currency Code"); // Direct Unit Cost is assigned directly; the following Validate("Line Discount %") recalculates the line amount. - PurchaseLine."Direct Unit Cost" := PurchaseLine.GetPurchaseDocumentSign() * TempBillingLine."Unit Price"; + PurchaseLine."Direct Unit Cost" := DirectUnitCost; PurchaseLine.Validate("Line Discount %", TempBillingLine."Discount %"); PurchaseLine."Recurring Billing from" := TempBillingLine."Billing from"; PurchaseLine."Recurring Billing to" := TempBillingLine."Billing to"; @@ -449,6 +479,7 @@ codeunit 8060 "Create Billing Documents" var UsageDataBilling: Record "Usage Data Billing"; ServiceCommitment: Record "Subscription Line"; + DirectUnitCost: Decimal; begin if not ServiceCommitment.Get(BillingLine."Subscription Line Entry No.") then exit; @@ -470,9 +501,12 @@ codeunit 8060 "Create Billing Documents" UsageDataBilling.SetRange(Quantity); UsageDataBilling.CalcSums("Cost Amount"); if PurchLine.Quantity <> 0 then - PurchLine.Validate("Direct Unit Cost", PurchLine.GetPurchaseDocumentSign() * UsageDataBilling."Cost Amount" / PurchLine.Quantity) + DirectUnitCost := PurchLine.GetPurchaseDocumentSign() * UsageDataBilling."Cost Amount" / PurchLine.Quantity else - PurchLine.Validate("Direct Unit Cost", 0); + DirectUnitCost := 0; + if PurchaseHeader."Prices Including VAT" then + DirectUnitCost := ToVATInclusiveUnitPrice(DirectUnitCost, PurchLine."VAT %", PurchLine."VAT Calculation Type" = PurchLine."VAT Calculation Type"::"Full VAT", PurchLine."Currency Code"); + PurchLine.Validate("Direct Unit Cost", DirectUnitCost); PurchLine.Validate("Line Discount %", ServiceCommitment."Discount %"); end; diff --git a/src/Apps/W1/Subscription Billing/Test/Billing/RecurringBillingDocsTest.Codeunit.al b/src/Apps/W1/Subscription Billing/Test/Billing/RecurringBillingDocsTest.Codeunit.al index 6b3064ac319..336ee773160 100644 --- a/src/Apps/W1/Subscription Billing/Test/Billing/RecurringBillingDocsTest.Codeunit.al +++ b/src/Apps/W1/Subscription Billing/Test/Billing/RecurringBillingDocsTest.Codeunit.al @@ -1,7 +1,9 @@ namespace Microsoft.SubscriptionBilling; +using Microsoft.Finance.Currency; using Microsoft.Finance.GeneralLedger.Account; using Microsoft.Finance.GeneralLedger.Journal; +using Microsoft.Finance.VAT.Setup; using Microsoft.Foundation.UOM; using Microsoft.Inventory.Item; using Microsoft.Inventory.Item.Attribute; @@ -717,6 +719,170 @@ codeunit 139687 "Recurring Billing Docs Test" SalesInvoiceHeader.TestField("Recurring Billing", true); end; + [Test] + [HandlerFunctions('CreateCustomerBillingDocsContractPageHandler,MessageHandler')] + procedure ContractSalesInvoiceUnitPriceIsVATInclusiveWhenCustomerPricesIncludeVAT() + var + Currency: Record Currency; + VATPostingSetup: Record "VAT Posting Setup"; + NetUnitPrice: Decimal; + ExpectedUnitPrice: Decimal; + begin + // [SCENARIO] When a Subscription Contract is billed for a customer whose prices include VAT, + // the resulting contract invoice sales line "Unit Price" is grossed up to a VAT-inclusive value. + Initialize(); + + // [GIVEN] A customer with "Prices Including VAT" = YES and a subscription item with a non-zero VAT rate + CreateCustomerContractWithVATItem(VATPostingSetup, true); + + // [GIVEN] A billing proposal for the contract + ContractTestLibrary.CreateBillingProposal(BillingTemplate, Enum::"Service Partner"::Customer); + + // [WHEN] Billing documents are created + CreateBillingDocuments(false); + + // [THEN] The created sales invoice has "Prices Including VAT" = YES + BillingLine.Reset(); + BillingLine.SetRange("Billing Template Code", BillingTemplate.Code); + BillingLine.SetRange(Partner, BillingTemplate.Partner); + BillingLine.FindFirst(); + NetUnitPrice := SumBillingProposalUnitPrice(); + Assert.AreNotEqual(0, NetUnitPrice, 'The net unit price should not be zero, otherwise the test is meaningless.'); + + SalesHeader.Get(Enum::"Sales Document Type"::Invoice, BillingLine."Document No."); + SalesHeader.TestField("Prices Including VAT", true); + + // [THEN] The sales line unit price equals the net price grossed up by VAT + SalesLine.Reset(); + FilterSalesLineOnDocumentLine(BillingLine.GetSalesDocumentTypeFromBillingDocumentType(), BillingLine."Document No.", BillingLine."Document Line No."); + SalesLine.FindFirst(); + SalesLine.TestField("VAT %", VATPostingSetup."VAT %"); + + Currency.InitRoundingPrecision(); + ExpectedUnitPrice := Round(NetUnitPrice * (1 + VATPostingSetup."VAT %" / 100), Currency."Unit-Amount Rounding Precision"); + Assert.AreEqual(ExpectedUnitPrice, SalesLine."Unit Price", 'Contract invoice unit price should be VAT-inclusive when the customer prices include VAT.'); + end; + + [Test] + [HandlerFunctions('CreateCustomerBillingDocsContractPageHandler,MessageHandler')] + procedure ContractSalesInvoiceUnitPriceIsNetWhenCustomerPricesExcludeVAT() + var + VATPostingSetup: Record "VAT Posting Setup"; + NetUnitPrice: Decimal; + begin + // [SCENARIO] When a Subscription Contract is billed for a customer whose prices exclude VAT, + // the contract invoice sales line "Unit Price" stays net (no gross-up is applied). + Initialize(); + + // [GIVEN] A customer with "Prices Including VAT" = NO and a subscription item with a non-zero VAT rate + CreateCustomerContractWithVATItem(VATPostingSetup, false); + + // [GIVEN] A billing proposal for the contract + ContractTestLibrary.CreateBillingProposal(BillingTemplate, Enum::"Service Partner"::Customer); + + // [WHEN] Billing documents are created + CreateBillingDocuments(false); + + // [THEN] The created sales invoice has "Prices Including VAT" = NO + BillingLine.Reset(); + BillingLine.SetRange("Billing Template Code", BillingTemplate.Code); + BillingLine.SetRange(Partner, BillingTemplate.Partner); + BillingLine.FindFirst(); + NetUnitPrice := SumBillingProposalUnitPrice(); + Assert.AreNotEqual(0, NetUnitPrice, 'The net unit price should not be zero, otherwise the test is meaningless.'); + + SalesHeader.Get(Enum::"Sales Document Type"::Invoice, BillingLine."Document No."); + SalesHeader.TestField("Prices Including VAT", false); + + // [THEN] The sales line unit price equals the net price (unchanged) + SalesLine.Reset(); + FilterSalesLineOnDocumentLine(BillingLine.GetSalesDocumentTypeFromBillingDocumentType(), BillingLine."Document No.", BillingLine."Document Line No."); + SalesLine.FindFirst(); + Assert.AreEqual(NetUnitPrice, SalesLine."Unit Price", 'Contract invoice unit price should stay net when the customer prices exclude VAT.'); + end; + + [Test] + [HandlerFunctions('CreateVendorBillingDocsContractPageHandler,MessageHandler')] + procedure ContractPurchInvoiceUnitCostIsVATInclusiveWhenVendorPricesIncludeVAT() + var + Currency: Record Currency; + VATPostingSetup: Record "VAT Posting Setup"; + NetUnitCost: Decimal; + ExpectedUnitCost: Decimal; + begin + // [SCENARIO] When a Vendor Subscription Contract is billed for a vendor whose prices include VAT, + // the resulting contract purchase invoice line "Direct Unit Cost" is grossed up to a VAT-inclusive value. + Initialize(); + + // [GIVEN] A vendor with "Prices Including VAT" = YES and a subscription item with a non-zero VAT rate + CreateVendorContractWithVATItem(VATPostingSetup, true); + + // [GIVEN] A billing proposal for the contract + ContractTestLibrary.CreateBillingProposal(BillingTemplate, Enum::"Service Partner"::Vendor); + + // [WHEN] Billing documents are created + CreateBillingDocuments(false); + + // [THEN] The created purchase invoice has "Prices Including VAT" = YES + BillingLine.Reset(); + BillingLine.SetRange("Billing Template Code", BillingTemplate.Code); + BillingLine.SetRange(Partner, BillingTemplate.Partner); + BillingLine.FindFirst(); + NetUnitCost := SumBillingProposalUnitPrice(); + Assert.AreNotEqual(0, NetUnitCost, 'The net unit cost should not be zero, otherwise the test is meaningless.'); + + PurchaseHeader.Get(Enum::"Purchase Document Type"::Invoice, BillingLine."Document No."); + PurchaseHeader.TestField("Prices Including VAT", true); + + // [THEN] The purchase line direct unit cost equals the net cost grossed up by VAT + PurchaseLine.Reset(); + FilterPurchaseLineOnDocumentLine(PurchaseHeader."Document Type", BillingLine."Document No.", BillingLine."Document Line No."); + PurchaseLine.FindFirst(); + PurchaseLine.TestField("VAT %", VATPostingSetup."VAT %"); + + Currency.InitRoundingPrecision(); + ExpectedUnitCost := Round(NetUnitCost * (1 + VATPostingSetup."VAT %" / 100), Currency."Unit-Amount Rounding Precision"); + Assert.AreEqual(ExpectedUnitCost, PurchaseLine."Direct Unit Cost", 'Contract purchase invoice direct unit cost should be VAT-inclusive when the vendor prices include VAT.'); + end; + + [Test] + [HandlerFunctions('CreateVendorBillingDocsContractPageHandler,MessageHandler')] + procedure ContractPurchInvoiceUnitCostIsNetWhenVendorPricesExcludeVAT() + var + VATPostingSetup: Record "VAT Posting Setup"; + NetUnitCost: Decimal; + begin + // [SCENARIO] When a Vendor Subscription Contract is billed for a vendor whose prices exclude VAT, + // the contract purchase invoice line "Direct Unit Cost" stays net (no gross-up is applied). + Initialize(); + + // [GIVEN] A vendor with "Prices Including VAT" = NO and a subscription item with a non-zero VAT rate + CreateVendorContractWithVATItem(VATPostingSetup, false); + + // [GIVEN] A billing proposal for the contract + ContractTestLibrary.CreateBillingProposal(BillingTemplate, Enum::"Service Partner"::Vendor); + + // [WHEN] Billing documents are created + CreateBillingDocuments(false); + + // [THEN] The created purchase invoice has "Prices Including VAT" = NO + BillingLine.Reset(); + BillingLine.SetRange("Billing Template Code", BillingTemplate.Code); + BillingLine.SetRange(Partner, BillingTemplate.Partner); + BillingLine.FindFirst(); + NetUnitCost := SumBillingProposalUnitPrice(); + Assert.AreNotEqual(0, NetUnitCost, 'The net unit cost should not be zero, otherwise the test is meaningless.'); + + PurchaseHeader.Get(Enum::"Purchase Document Type"::Invoice, BillingLine."Document No."); + PurchaseHeader.TestField("Prices Including VAT", false); + + // [THEN] The purchase line direct unit cost equals the net cost (unchanged) + PurchaseLine.Reset(); + FilterPurchaseLineOnDocumentLine(PurchaseHeader."Document Type", BillingLine."Document No.", BillingLine."Document Line No."); + PurchaseLine.FindFirst(); + Assert.AreEqual(NetUnitCost, PurchaseLine."Direct Unit Cost", 'Contract purchase invoice direct unit cost should stay net when the vendor prices exclude VAT.'); + end; + [Test] [HandlerFunctions('CheckDialogConfirmHandler,ExchangeRateSelectionModalPageHandler,CreateVendorBillingDocsTestOpenPageHandler,MessageHandler')] procedure CheckVendorBillingProposalCanBeCreatedForPurchaseCrMemoExists() @@ -2718,6 +2884,83 @@ codeunit 139687 "Recurring Billing Docs Test" CustomerContractLine.GetServiceCommitment(ServiceCommitment); end; + local procedure CreateCustomerContractWithVATItem(var VATPostingSetup: Record "VAT Posting Setup"; PricesIncludingVAT: Boolean) + var + Customer: Record Customer; + Item: Record Item; + begin + FindNonZeroVATPostingSetup(VATPostingSetup); + + ContractTestLibrary.CreateCustomerInLCY(Customer); + Customer.Validate("VAT Bus. Posting Group", VATPostingSetup."VAT Bus. Posting Group"); + Customer.Validate("Prices Including VAT", PricesIncludingVAT); + Customer.Modify(true); + + ContractTestLibrary.CreateCustomerContractAndCreateContractLinesForItems(CustomerContract, ServiceObject, Customer."No."); + ContractTestLibrary.DisableDeferralsForCustomerContract(CustomerContract, false); + + // Assign the non-zero VAT rate to the invoicing item so the contract invoice line carries VAT + GetCustomerContractServiceCommitment(CustomerContract."No."); + Item.Get(ServiceCommitment."Invoicing Item No."); + Item.Validate("VAT Prod. Posting Group", VATPostingSetup."VAT Prod. Posting Group"); + Item.Modify(true); + + // Set a deterministic, non-zero net price on the Subscription Line + ServiceCommitment.Validate("Calculation Base Amount", LibraryRandom.RandDecInRange(100, 1000, 2)); + ServiceCommitment.Validate("Calculation Base %", 100); + ServiceCommitment.Modify(true); + end; + + local procedure CreateVendorContractWithVATItem(var VATPostingSetup: Record "VAT Posting Setup"; PricesIncludingVAT: Boolean) + var + Vendor: Record Vendor; + Item: Record Item; + begin + FindNonZeroVATPostingSetup(VATPostingSetup); + + ContractTestLibrary.CreateVendorInLCY(Vendor); + Vendor.Validate("VAT Bus. Posting Group", VATPostingSetup."VAT Bus. Posting Group"); + Vendor.Validate("Prices Including VAT", PricesIncludingVAT); + Vendor.Modify(true); + + ContractTestLibrary.CreateVendorContractAndCreateContractLinesForItems(VendorContract, ServiceObject, Vendor."No."); + ContractTestLibrary.DisableDeferralsForVendorContract(VendorContract, false); + + // Assign the non-zero VAT rate to the invoicing item so the contract purchase invoice line carries VAT + GetVendorContractServiceCommitment(VendorContract."No."); + Item.Get(ServiceCommitment."Invoicing Item No."); + Item.Validate("VAT Prod. Posting Group", VATPostingSetup."VAT Prod. Posting Group"); + Item.Modify(true); + + // Set a deterministic, non-zero net price on the Subscription Line + ServiceCommitment.Validate("Calculation Base Amount", LibraryRandom.RandDecInRange(100, 1000, 2)); + ServiceCommitment.Validate("Calculation Base %", 100); + ServiceCommitment.Modify(true); + end; + + local procedure FindNonZeroVATPostingSetup(var VATPostingSetup: Record "VAT Posting Setup") + begin + LibraryERM.FindVATPostingSetupInvt(VATPostingSetup); + if VATPostingSetup."VAT %" = 0 then begin + VATPostingSetup."VAT %" := LibraryRandom.RandDecInRange(10, 25, 0); + VATPostingSetup.Modify(false); + end; + end; + + local procedure SumBillingProposalUnitPrice(): Decimal + var + LocalBillingLine: Record "Billing Line"; + TotalUnitPrice: Decimal; + begin + LocalBillingLine.SetRange("Billing Template Code", BillingTemplate.Code); + LocalBillingLine.SetRange(Partner, BillingTemplate.Partner); + if LocalBillingLine.FindSet() then + repeat + TotalUnitPrice += LocalBillingLine."Unit Price"; + until LocalBillingLine.Next() = 0; + exit(TotalUnitPrice); + end; + local procedure GetNoOfSalesInvoiceLineWithDescription(ExpectedDescriptionText: Text[100]): Integer begin BillingLine.Reset();