Skip to content

Fix #7399: Strip VAT from Subscription Line base amount when Prices Including VAT is enabled#7780

Open
jeffreybulanadi wants to merge 4 commits intomicrosoft:mainfrom
jeffreybulanadi:fix/7399-subscription-billing-prices-including-vat
Open

Fix #7399: Strip VAT from Subscription Line base amount when Prices Including VAT is enabled#7780
jeffreybulanadi wants to merge 4 commits intomicrosoft:mainfrom
jeffreybulanadi:fix/7399-subscription-billing-prices-including-vat

Conversation

@jeffreybulanadi
Copy link
Copy Markdown

@jeffreybulanadi jeffreybulanadi commented Apr 21, 2026

Summary

Fixes #7399

When Prices Including VAT is enabled on a Sales Header, the Calculation Base Amount on Sales Subscription Lines was incorrectly storing the VAT-inclusive unit price instead of the net price. Additionally, toggling the flag on an existing document did not trigger recalculation of already-created subscription lines.

Root Cause

Bug 1 - Wrong amount at line creation:
CalculateCalculationBaseAmountCustomer copied SalesLine."Unit Price" directly into Calculation Base Amount without checking SalesHeader."Prices Including VAT". When the flag is true, SalesLine."Unit Price" is VAT-inclusive, so subscription lines stored a gross price.

Bug 2 - No recalculation on PIV toggle:
UpdateSalesServiceCommitmentCalculationBaseAmount in SalesLine.TableExt.al exits early when Unit Price has not changed. Toggling Prices Including VAT on the Sales Header does not change the numeric value of Unit Price (same number, different interpretation), so the early-exit fires and subscription lines are never updated.

Changes

  • CalculateCalculationBaseAmountCustomer: call GetSalesHeader to read the Prices Including VAT flag and strip VAT from the calculated base amount using the formula Amount / (1 + VAT% / 100) when the flag is true.
  • GetSalesHeader: add an override path using SalesHeaderOverride so that an in-memory Sales Header record can be injected. This is required because OnAfterValidateEvent fires before the record is committed to the database, so a direct Get would return the old value.
  • SetSalesHeader: new procedure following the same pattern as SetSalesLine, allowing callers to inject an in-memory Sales Header for accurate Prices Including VAT reads.
  • SalesSubscriptionLineMgmt: add event subscriber on Sales Header OnAfterValidateEvent for Prices Including VAT to recalculate all Sales Subscription Lines when the flag is toggled.

How to Test

  1. Create a VAT posting setup with a non-zero VAT% (e.g. 21%).
  2. Create an item with a Subscription Package.
  3. Create a Sales Order with Prices Including VAT = true.
  4. Add a sales line with the subscription item at unit price 121.
  5. Verify that the Sales Subscription Line Calculation Base Amount = 100 (net price, VAT stripped).
  6. Toggle Prices Including VAT to false on the Sales Order.
  7. Verify that the Sales Subscription Line Calculation Base Amount updates to 121 (gross price, no stripping).
  8. Toggle back to true and verify the amount returns to 100.

… Prices Including VAT is enabled

When Prices Including VAT is toggled on a Sales Header, the Calculation
Base Amount on Sales Subscription Lines was storing the VAT-inclusive unit
price instead of the net price.

Changes:
- CalculateCalculationBaseAmountCustomer: call GetSalesHeader to read the
  Prices Including VAT flag and strip VAT from the calculated base amount
  using the formula Amount / (1 + VAT% / 100) when the flag is true.
- GetSalesHeader: add override check using SalesHeaderOverride so that an
  in-memory Sales Header record can be injected before a Modify call (the
  OnAfterValidateEvent fires before the record is committed to the database).
- SetSalesHeader: new procedure matching the SetSalesLine pattern, allowing
  callers to inject an in-memory Sales Header for accurate PIV reads.
- SalesSubscriptionLineMgmt: add event subscriber on Sales Header
  OnAfterValidateEvent for Prices Including VAT to recalculate all Sales
  Subscription Lines when the flag is toggled.
@jeffreybulanadi jeffreybulanadi requested a review from a team as a code owner April 21, 2026 09:33
@github-actions github-actions Bot added AL: Apps (W1) Add-on apps for W1 From Fork Pull request is coming from a fork labels Apr 21, 2026
Copy link
Copy Markdown
Contributor

@pri-kise pri-kise left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Will you add some automatic tests?

then begin
OutSalesHeader := SalesHeaderOverride;
if SalesHeaderOverride."Currency Code" = '' then
Currency.InitRoundingPrecision()
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Not the Currency TestFields are missing in this case?
Is this on purpose?

end;
end;
if LocalSalesHeader."Prices Including VAT" then
CalculatedBaseAmount := Round(CalculatedBaseAmount / (1 + SalesLine."VAT %" / 100), Currency."Unit-Amount Rounding Precision");
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Microsoft introduced the procedure GetVATPct in the SalesLine some versions ago.
https://github.com/microsoft/BusinessCentralApps/blob/020503c3e3530c3fe56890c4a409afdf22037b45/App/Layers/W1/BaseApp/Sales/Document/SalesLine.Table.al#L11028-L11036
I'm not sure why the did this, but this used anywhere in the Salesline table.
Therfore I would suggest to use it, too.

…de path

- Replace SalesLine."VAT %" with SalesLine.GetVATPct() to follow the
  Microsoft extensibility pattern introduced in SalesLine.Table.al,
  which raises OnAfterGetVATPct to allow extensions to override the rate.

- In GetSalesHeader(), the SalesHeaderOverride branch was missing the
  same Currency.TestField calls present in the database-read branch.
  Add SalesHeaderOverride.TestField("Currency Factor"),
  Currency.TestField("Unit-Amount Rounding Precision"), and
  Currency.TestField("Amount Rounding Precision") so both branches
  are fully symmetric.
Copilot AI review requested due to automatic review settings April 22, 2026 13:32
Copy link
Copy Markdown
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Fixes Subscription Billing’s Sales Subscription Line “Calculation Base Amount” so it reflects the correct net unit price when Prices Including VAT is enabled, and ensures existing subscription lines are recalculated when the header flag is toggled.

Changes:

  • Strip VAT from calculated base amount when Sales Header."Prices Including VAT" is true.
  • Add an in-memory Sales Header override path (SetSalesHeader + SalesHeaderOverride) so calculations during validation read the updated flag value.
  • Add a Sales Header OnAfterValidateEvent subscriber for Prices Including VAT to recalculate existing Sales Subscription Lines.

Reviewed changes

Copilot reviewed 2 out of 2 changed files in this pull request and generated 3 comments.

File Description
src/Apps/W1/Subscription Billing/App/Sales Service Commitments/Tables/SalesSubscriptionLine.Table.al Reads Prices Including VAT via GetSalesHeader, strips VAT from base amount, and supports injected Sales Header override via SetSalesHeader.
src/Apps/W1/Subscription Billing/App/Sales Service Commitments/Codeunits/SalesSubscriptionLineMgmt.Codeunit.al Recalculates Sales Subscription Lines when Prices Including VAT is toggled on Sales Header.

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment on lines +390 to +413
[EventSubscriber(ObjectType::Table, Database::"Sales Header", OnAfterValidateEvent, "Prices Including VAT", false, false)]
local procedure RecalculateSalesSubscriptionLinesOnAfterSalesHeaderPricesIncludingVATValidate(var Rec: Record "Sales Header"; var xRec: Record "Sales Header"; CurrFieldNo: Integer)
var
SalesLine: Record "Sales Line";
SalesServiceCommitment: Record "Sales Subscription Line";
begin
if Rec.IsTemporary() then
exit;
if Rec."Prices Including VAT" = xRec."Prices Including VAT" then
exit;
SalesLine.SetRange("Document Type", Rec."Document Type");
SalesLine.SetRange("Document No.", Rec."No.");
if not SalesLine.FindSet() then
exit;
repeat
SalesServiceCommitment.FilterOnSalesLine(SalesLine);
if SalesServiceCommitment.FindSet() then
repeat
SalesServiceCommitment.SetSalesLine(SalesLine);
SalesServiceCommitment.SetSalesHeader(Rec);
SalesServiceCommitment.CalculateCalculationBaseAmount();
until SalesServiceCommitment.Next() = 0;
until SalesLine.Next() = 0;
end;
Copy link

Copilot AI Apr 22, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

New behavior (stripping VAT from calculation base amount when Prices Incl. VAT is enabled, and recalculation when toggling the flag) is not covered by automated tests. There is an existing Sales Service Commitment Test suite; adding a test that (1) creates a sales order with non-zero VAT%, Prices Incl. VAT=true and verifies the created Sales Subscription Line base amount is net, and (2) toggles Prices Incl. VAT and verifies base amount updates, would prevent regressions.

Copilot uses AI. Check for mistakes.
Comment on lines +532 to +533
if LocalSalesHeader."Prices Including VAT" then
CalculatedBaseAmount := Round(CalculatedBaseAmount / (1 + SalesLine.GetVATPct() / 100), Currency."Unit-Amount Rounding Precision");
Copy link

Copilot AI Apr 22, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Prices Including VAT handling here uses SalesLine.GetVATPct() and a generic gross-to-net division. In this app, VAT% is explicitly treated as 0 for Reverse Charge VAT/Sales Tax (see existing VAT buffer logic), and Sales Tax requires SalesTaxCalculate.ReverseCalculateTax(...) to derive the net price. As-is, Sales Tax documents (and potentially reverse charge depending on what GetVATPct() returns) can keep a tax-inclusive base amount or be reverse-calculated incorrectly. Consider branching on SalesLine."VAT Calculation Type": for Sales Tax use ReverseCalculateTax with the sales header’s tax fields; for Reverse Charge VAT treat the effective % as 0 (no stripping).

Copilot uses AI. Check for mistakes.
Comment on lines +400 to +412
SalesLine.SetRange("Document Type", Rec."Document Type");
SalesLine.SetRange("Document No.", Rec."No.");
if not SalesLine.FindSet() then
exit;
repeat
SalesServiceCommitment.FilterOnSalesLine(SalesLine);
if SalesServiceCommitment.FindSet() then
repeat
SalesServiceCommitment.SetSalesLine(SalesLine);
SalesServiceCommitment.SetSalesHeader(Rec);
SalesServiceCommitment.CalculateCalculationBaseAmount();
until SalesServiceCommitment.Next() = 0;
until SalesLine.Next() = 0;
Copy link

Copilot AI Apr 22, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This subscriber recalculates subscription lines for every sales line on the document and recalculates vendor-partner subscription lines as well. Since Prices Including VAT only affects customer pricing, consider filtering to Partner::Customer (and possibly Process/other relevant filters) before FindSet() to avoid unnecessary queries/modifications, and/or iterating subscription lines via FilterOnDocument(Rec."Document Type", Rec."No.") to reduce work on documents with many non-subscription sales lines.

Copilot uses AI. Check for mistakes.
Three tests added to SalesServiceCommitmentTest (codeunit 139915) that
cover both bugs fixed in this PR:

Bug 1 - Wrong amount at creation:
  - BaseAmountIsNetPriceWhenPricesIncludingVATEnabled: verifies that
    when Prices Including VAT = true, Calculation Base Amount is
    Round(UnitPrice / (1 + VAT% / 100)) not the gross unit price.
  - BaseAmountEqualsUnitPriceWhenPricesExcludingVAT: regression guard
    confirming that the standard (PIV = false) path is unaffected.

Bug 2 - No recalculation on PIV toggle:
  - TogglingPricesIncludingVATRecalculatesBaseAmount: verifies that
    validating Prices Including VAT on an existing Sales Header
    recalculates all subscription line base amounts in both directions
    (false to true, then back to false).
@jeffreybulanadi
Copy link
Copy Markdown
Author

Addressing review comments (commits 9be59b7 + 2fc0d32):

Re: Currency TestFields missing in override path
Fixed - SalesHeaderOverride branch now has full parity with the database-read path:

else begin
    SalesHeaderOverride.TestField("Currency Factor");
    Currency.Get(SalesHeaderOverride."Currency Code");
    Currency.TestField("Unit-Amount Rounding Precision");
    Currency.TestField("Amount Rounding Precision");
end;

Re: Use GetVATPct() instead of direct field access
Fixed - SalesLine.GetVATPct() is now used to follow the extensibility pattern (raises OnAfterGetVATPct).

Automated tests
Three tests added to SalesServiceCommitmentTest (codeunit 139915, SalesServiceCommitmentTest.Codeunit.al) covering both bugs:

  • BaseAmountIsNetPriceWhenPricesIncludingVATEnabled - creates a Sales Order with Prices Including VAT = true, asserts Calculation Base Amount = Round(UnitPrice / (1 + VAT% / 100), Unit-Amount Rounding Precision).
  • BaseAmountEqualsUnitPriceWhenPricesExcludingVAT - regression guard for the standard PIV = false path (base amount must equal unit price, no stripping).
  • TogglingPricesIncludingVATRecalculatesBaseAmount - starts with PIV = false, toggles to true, verifies base amount recalculates to net price; toggles back to false, verifies it reverts to unit price.

…ubscriber to Customer lines

CalculateCalculationBaseAmountCustomer: restrict the gross-to-net
division to VAT Calculation Type = Normal VAT. Reverse Charge VAT
and Sales Tax are excluded: for Reverse Charge VAT the effective
rate is already zero at the line level so no stripping is needed;
for Sales Tax the simple division formula does not apply.

RecalculateSalesSubscriptionLinesOnAfterSalesHeaderPricesIncludingVATValidate:
replace the outer SalesLine loop with FilterOnDocument plus
SetRange(Partner::Customer) before FindSet. Each subscription line
is retrieved by SalesLine.Get to skip non-subscription lines and
avoid unnecessary queries. Vendor-partner lines are not affected
by Prices Including VAT and are now correctly excluded.
@jeffreybulanadi jeffreybulanadi force-pushed the fix/7399-subscription-billing-prices-including-vat branch from 2fc0d32 to 44f8da6 Compare April 22, 2026 23:38
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

AL: Apps (W1) Add-on apps for W1 From Fork Pull request is coming from a fork

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[Bug][SubscriptionBilling]: "Prices Including VAT" option does not correctly update Subscription Line amounts

3 participants