From 8e74942bb8547f9d0963ac18e73332bb5e4c7b2e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Joshua=20Mart=C3=ADnez=20Pineda?= Date: Thu, 3 Sep 2026 17:14:10 +0200 Subject: [PATCH 1/4] BE compensation for payment discounts --- .../App/src/PEPPOL30BEEscompte.Codeunit.al | 78 +++++++++++ .../src/PEPPOL30BEMonetaryInfo.Codeunit.al | 104 ++++++++++++++ .../App/src/PEPPOL30BEPaymentInfo.Codeunit.al | 67 +++++++++ .../App/src/PEPPOL30BETaxInfo.Codeunit.al | 39 ++++-- .../App/src/PEPPOL30FormatBE.EnumExt.al | 8 +- .../App/src/Common/PEPPOL30Common.Codeunit.al | 2 + .../PEPPOLTaxInfoProvider.Interface.al | 9 ++ .../XmlPorts/SalesCrMemoPEPPOL30.XmlPort.al | 6 + .../XmlPorts/SalesInvoicePEPPOL30.XmlPort.al | 6 + .../Sales/Peppol/PEPPOLManagement.Codeunit.al | 127 ++++++++++++++++++ .../Peppol/SalesInvoicePEPPOLBIS30.XmlPort.al | 8 ++ 11 files changed, 444 insertions(+), 10 deletions(-) create mode 100644 src/Apps/BE/PeppolBE/App/src/PEPPOL30BEEscompte.Codeunit.al create mode 100644 src/Apps/BE/PeppolBE/App/src/PEPPOL30BEMonetaryInfo.Codeunit.al create mode 100644 src/Apps/BE/PeppolBE/App/src/PEPPOL30BEPaymentInfo.Codeunit.al diff --git a/src/Apps/BE/PeppolBE/App/src/PEPPOL30BEEscompte.Codeunit.al b/src/Apps/BE/PeppolBE/App/src/PEPPOL30BEEscompte.Codeunit.al new file mode 100644 index 00000000000..08bae8bf923 --- /dev/null +++ b/src/Apps/BE/PeppolBE/App/src/PEPPOL30BEEscompte.Codeunit.al @@ -0,0 +1,78 @@ +// ------------------------------------------------------------------------------------------------ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. See License.txt in the project root for license information. +// ------------------------------------------------------------------------------------------------ +namespace Microsoft.Peppol.BE; + +using Microsoft.Finance.GeneralLedger.Setup; +using Microsoft.Finance.VAT.Calculation; +using Microsoft.Sales.Document; + +/// +/// Shared helpers for the Belgian payment-discount (escompte) compensation. In Belgium VAT is kept on the discounted base, even when the invoice is reported with the full amount. +/// To avoid reporting a reduced amount payable (the discount is only conditional) a compensating Exempt (category E) breakdown line is added. +/// +codeunit 37316 "PEPPOL30 BE Escompte" +{ + Access = Internal; + InherentEntitlements = X; + InherentPermissions = X; + + var + CompensationChargeReasonTxt: Label 'Payment discount not deducted from the amount payable'; + CompensationExemptionReasonTxt: Label 'Conditional early-payment discount, not part of the taxable amount'; + + /// + /// The VAT Amount Line "VAT Identifier" used to mark this compensation line. Internal to the buffer, not written to the PEPPOL document. + /// + procedure GetCompensationVATIdentifier(): Code[20] + begin + exit('ESCOMPTE-COMP'); + end; + + /// + /// The PEPPOL VAT category code used for the compensation line and charge. + /// + procedure GetExemptTaxCategory(): Code[10] + begin + exit('E'); + end; + + /// + /// Returns whether the given VAT amount line is the synthetic escompte compensation line. + /// + procedure IsCompensationLine(VATAmtLine: Record "VAT Amount Line"): Boolean + begin + exit(VATAmtLine."VAT Identifier" = GetCompensationVATIdentifier()); + end; + + /// + /// The AllowanceChargeReason used on the compensating Exempt charge. + /// + procedure GetCompensationChargeReason(): Text + begin + exit(CompensationChargeReasonTxt); + end; + + /// + /// The VAT exemption reason used on the compensating Exempt VAT breakdown. + /// + procedure GetCompensationExemptionReason(): Text + begin + exit(CompensationExemptionReasonTxt); + end; + + /// + /// Document's currency code (inline with PEPPOL's implementation). + /// + procedure DocumentCurrencyCode(SalesHeader: Record "Sales Header"): Text + var + GLSetup: Record "General Ledger Setup"; + begin + if SalesHeader."Currency Code" <> '' then + exit(SalesHeader."Currency Code"); + GLSetup.Get(); + GLSetup.TestField("LCY Code"); + exit(GLSetup."LCY Code"); + end; +} diff --git a/src/Apps/BE/PeppolBE/App/src/PEPPOL30BEMonetaryInfo.Codeunit.al b/src/Apps/BE/PeppolBE/App/src/PEPPOL30BEMonetaryInfo.Codeunit.al new file mode 100644 index 00000000000..79dee0965ce --- /dev/null +++ b/src/Apps/BE/PeppolBE/App/src/PEPPOL30BEMonetaryInfo.Codeunit.al @@ -0,0 +1,104 @@ +// ------------------------------------------------------------------------------------------------ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. See License.txt in the project root for license information. +// ------------------------------------------------------------------------------------------------ +namespace Microsoft.Peppol.BE; + +using Microsoft.Finance.VAT.Calculation; +using Microsoft.Peppol; +using Microsoft.Sales.Document; + +/// +/// Needed to add into the LegalMonetaryTotal the Belgian escompte compensation (if applicable). We are storing the compensation in the VAT Amount Line records. +/// +codeunit 37318 "PEPPOL30 BE Monetary Info" implements "PEPPOL Monetary Info Provider" +{ + Access = Internal; + InherentEntitlements = X; + InherentPermissions = X; + + var + PEPPOL30: Codeunit "PEPPOL30"; + Escompte: Codeunit "PEPPOL30 BE Escompte"; + + procedure GetLegalMonetaryInfo(SalesHeader: Record "Sales Header"; var TempSalesLine: Record "Sales Line" temporary; var VATAmtLine: Record "VAT Amount Line"; var LineExtensionAmount: Text; var LegalMonetaryTotalCurrencyID: Text; var TaxExclusiveAmount: Text; var TaxExclusiveAmountCurrencyID: Text; var TaxInclusiveAmount: Text; var TaxInclusiveAmountCurrencyID: Text; var AllowanceTotalAmount: Text; var AllowanceTotalAmountCurrencyID: Text; var ChargeTotalAmount: Text; var ChargeTotalAmountCurrencyID: Text; var PrepaidAmount: Text; var PrepaidCurrencyID: Text; var PayableRoundingAmount: Text; var PayableRndingAmountCurrencyID: Text; var PayableAmount: Text; var PayableAmountCurrencyID: Text) + var + CompensationAmount: Decimal; + RealVATBase: Decimal; + RealInvDiscount: Decimal; + RealPmtDiscount: Decimal; + RealAmtInclVAT: Decimal; + CurrencyId: Text; + begin + if not HasCompensationLine(VATAmtLine) then begin + PEPPOL30.GetLegalMonetaryInfo(SalesHeader, TempSalesLine, VATAmtLine, LineExtensionAmount, LegalMonetaryTotalCurrencyID, TaxExclusiveAmount, TaxExclusiveAmountCurrencyID, TaxInclusiveAmount, TaxInclusiveAmountCurrencyID, AllowanceTotalAmount, AllowanceTotalAmountCurrencyID, ChargeTotalAmount, ChargeTotalAmountCurrencyID, PrepaidAmount, PrepaidCurrencyID, PayableRoundingAmount, PayableRndingAmountCurrencyID, PayableAmount, PayableAmountCurrencyID); + exit; + end; + + VATAmtLine.Reset(); + if VATAmtLine.FindSet() then + repeat + if Escompte.IsCompensationLine(VATAmtLine) then + CompensationAmount += VATAmtLine."VAT Base" + else begin + RealVATBase += VATAmtLine."VAT Base"; + RealInvDiscount += VATAmtLine."Invoice Discount Amount"; + RealPmtDiscount += VATAmtLine."Pmt. Discount Amount"; + RealAmtInclVAT += VATAmtLine."Amount Including VAT"; + end; + until VATAmtLine.Next() = 0; + + CurrencyId := Escompte.DocumentCurrencyCode(SalesHeader); + + LineExtensionAmount := Format(Round(RealVATBase, 0.01) + Round(RealInvDiscount, 0.01), 0, 9); + LegalMonetaryTotalCurrencyID := CurrencyId; + + TaxExclusiveAmount := Format(Round(RealVATBase - RealPmtDiscount + CompensationAmount, 0.01), 0, 9); + TaxExclusiveAmountCurrencyID := CurrencyId; + + TaxInclusiveAmount := Format(Round(RealAmtInclVAT - RealPmtDiscount + CompensationAmount, 0.01, '>'), 0, 9); + TaxInclusiveAmountCurrencyID := CurrencyId; + + AllowanceTotalAmount := Format(Round(RealInvDiscount + RealPmtDiscount, 0.01), 0, 9); + AllowanceTotalAmountCurrencyID := CurrencyId; + + ChargeTotalAmount := Format(Round(CompensationAmount, 0.01), 0, 9); + ChargeTotalAmountCurrencyID := CurrencyId; + + PrepaidAmount := '0.00'; + PrepaidCurrencyID := CurrencyId; + + if TempSalesLine."Line No." = 0 then begin + PayableRoundingAmount := Format(RealAmtInclVAT - Round(RealAmtInclVAT, 0.01), 0, 9); + PayableRndingAmountCurrencyID := CurrencyId; + PayableAmount := Format(Round(RealAmtInclVAT - RealPmtDiscount + CompensationAmount, 0.01), 0, 9); + PayableAmountCurrencyID := CurrencyId; + end else begin + PayableRoundingAmount := Format(TempSalesLine."Amount Including VAT", 0, 9); + PayableRndingAmountCurrencyID := CurrencyId; + PayableAmount := Format(Round(RealAmtInclVAT + TempSalesLine."Amount Including VAT" - RealPmtDiscount + CompensationAmount, 0.01), 0, 9); + PayableAmountCurrencyID := CurrencyId; + end; + end; + + procedure GetLegalMonetaryDocAmounts(SalesHeader: Record "Sales Header"; var VATAmtLine: Record "VAT Amount Line"; var LineExtensionAmount: Text; var LegalMonetaryTotalCurrencyID: Text; var TaxExclusiveAmount: Text; var TaxExclusiveAmountCurrencyID: Text; var TaxInclusiveAmount: Text; var TaxInclusiveAmountCurrencyID: Text; var AllowanceTotalAmount: Text; var AllowanceTotalAmountCurrencyID: Text; var ChargeTotalAmount: Text; var ChargeTotalAmountCurrencyID: Text) + begin + PEPPOL30.GetLegalMonetaryDocAmounts(SalesHeader, VATAmtLine, LineExtensionAmount, LegalMonetaryTotalCurrencyID, TaxExclusiveAmount, TaxExclusiveAmountCurrencyID, TaxInclusiveAmount, TaxInclusiveAmountCurrencyID, AllowanceTotalAmount, AllowanceTotalAmountCurrencyID, ChargeTotalAmount, ChargeTotalAmountCurrencyID); + end; + + procedure GetInvoiceRoundingLine(var TempSalesLine: Record "Sales Line" temporary; SalesLine: Record "Sales Line") + begin + PEPPOL30.GetInvoiceRoundingLine(TempSalesLine, SalesLine); + end; + + local procedure HasCompensationLine(var VATAmtLine: Record "VAT Amount Line"): Boolean + var + Found: Boolean; + begin + VATAmtLine.Reset(); + VATAmtLine.SetRange("VAT Identifier", Escompte.GetCompensationVATIdentifier()); + Found := not VATAmtLine.IsEmpty(); + VATAmtLine.Reset(); + exit(Found); + end; +} diff --git a/src/Apps/BE/PeppolBE/App/src/PEPPOL30BEPaymentInfo.Codeunit.al b/src/Apps/BE/PeppolBE/App/src/PEPPOL30BEPaymentInfo.Codeunit.al new file mode 100644 index 00000000000..58735b34b65 --- /dev/null +++ b/src/Apps/BE/PeppolBE/App/src/PEPPOL30BEPaymentInfo.Codeunit.al @@ -0,0 +1,67 @@ +// ------------------------------------------------------------------------------------------------ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. See License.txt in the project root for license information. +// ------------------------------------------------------------------------------------------------ +namespace Microsoft.Peppol.BE; + +using Microsoft.Finance.VAT.Calculation; +using Microsoft.Peppol; +using Microsoft.Sales.Document; + +/// +/// Belgian PEPPOL payment info provider. Delegates every method to the default PEPPOL30 implementation, except that it renders an extra escompte compensation line (see "PEPPOL30 BE Escompte") +/// +codeunit 37317 "PEPPOL30 BE Payment Info" implements "PEPPOL Payment Info Provider" +{ + Access = Internal; + InherentEntitlements = X; + InherentPermissions = X; + + var + PEPPOL30: Codeunit "PEPPOL30"; + Escompte: Codeunit "PEPPOL30 BE Escompte"; + + procedure GetPaymentMeansInfo(SalesHeader: Record "Sales Header"; var PaymentMeansCode: Text; var PaymentMeansListID: Text; var PaymentDueDate: Text; var PaymentChannelCode: Text; var PaymentID: Text; var PrimaryAccountNumberID: Text; var NetworkID: Text) + begin + PEPPOL30.GetPaymentMeansInfo(SalesHeader, PaymentMeansCode, PaymentMeansListID, PaymentDueDate, PaymentChannelCode, PaymentID, PrimaryAccountNumberID, NetworkID); + end; + + procedure GetPaymentMeansPayeeFinancialAcc(var PayeeFinancialAccountID: Text; var PaymentMeansSchemeID: Text; var FinancialInstitutionBranchID: Text; var FinancialInstitutionID: Text; var FinancialInstitutionSchemeID: Text; var FinancialInstitutionName: Text) + begin + PEPPOL30.GetPaymentMeansPayeeFinancialAcc(PayeeFinancialAccountID, PaymentMeansSchemeID, FinancialInstitutionBranchID, FinancialInstitutionID, FinancialInstitutionSchemeID, FinancialInstitutionName); + end; + + procedure GetPaymentMeansPayeeFinancialAccBIS(SalesHeader: Record "Sales Header"; var PayeeFinancialAccountID: Text; var FinancialInstitutionBranchID: Text) + begin + PEPPOL30.GetPaymentMeansPayeeFinancialAccBIS(SalesHeader, PayeeFinancialAccountID, FinancialInstitutionBranchID); + end; + + procedure GetPaymentMeansFinancialInstitutionAddr(var FinancialInstitutionStreetName: Text; var AdditionalStreetName: Text; var FinancialInstitutionCityName: Text; var FinancialInstitutionPostalZone: Text; var FinancialInstCountrySubentity: Text; var FinancialInstCountryIdCode: Text; var FinancialInstCountryListID: Text) + begin + PEPPOL30.GetPaymentMeansFinancialInstitutionAddr(FinancialInstitutionStreetName, AdditionalStreetName, FinancialInstitutionCityName, FinancialInstitutionPostalZone, FinancialInstCountrySubentity, FinancialInstCountryIdCode, FinancialInstCountryListID); + end; + + procedure GetPaymentTermsInfo(SalesHeader: Record "Sales Header"; var PaymentTermsNote: Text) + begin + PEPPOL30.GetPaymentTermsInfo(SalesHeader, PaymentTermsNote); + end; + + procedure GetAllowanceChargeInfoPaymentDiscount(VATAmtLine: Record "VAT Amount Line"; SalesHeader: Record "Sales Header"; var ChargeIndicator: Text; var AllowanceChargeReasonCode: Text; var AllowanceChargeListID: Text; var AllowanceChargeReason: Text; var Amount: Text; var AllowanceChargeCurrencyID: Text; var TaxCategoryID: Text; var TaxCategorySchemeID: Text; var Percent: Text; var AllowanceChargeTaxSchemeID: Text) + begin + if Escompte.IsCompensationLine(VATAmtLine) then begin + ChargeIndicator := 'true'; + AllowanceChargeReasonCode := ''; + AllowanceChargeListID := ''; + AllowanceChargeReason := Escompte.GetCompensationChargeReason(); + Amount := Format(VATAmtLine."VAT Base", 0, 9); + AllowanceChargeCurrencyID := Escompte.DocumentCurrencyCode(SalesHeader); + TaxCategoryID := VATAmtLine."Tax Category"; + TaxCategorySchemeID := ''; + Percent := Format(VATAmtLine."VAT %", 0, 9); + AllowanceChargeTaxSchemeID := 'VAT'; + exit; + end; + + PEPPOL30.GetAllowanceChargeInfoPaymentDiscount(VATAmtLine, SalesHeader, ChargeIndicator, AllowanceChargeReasonCode, AllowanceChargeListID, AllowanceChargeReason, Amount, AllowanceChargeCurrencyID, TaxCategoryID, TaxCategorySchemeID, Percent, AllowanceChargeTaxSchemeID); + end; +} diff --git a/src/Apps/BE/PeppolBE/App/src/PEPPOL30BETaxInfo.Codeunit.al b/src/Apps/BE/PeppolBE/App/src/PEPPOL30BETaxInfo.Codeunit.al index 0a0225d8e58..068c2d4e3c0 100644 --- a/src/Apps/BE/PeppolBE/App/src/PEPPOL30BETaxInfo.Codeunit.al +++ b/src/Apps/BE/PeppolBE/App/src/PEPPOL30BETaxInfo.Codeunit.al @@ -10,9 +10,10 @@ using Microsoft.Peppol; using Microsoft.Sales.Document; /// -/// Belgian PEPPOL tax info provider. Delegates every method to the default PEPPOL30 implementation, -/// except that it excludes the payment discount from the tax totals so that the PEPPOL document totals -/// (TaxableAmount, TaxExclusiveAmount, TaxInclusiveAmount, PayableAmount) match the invoice printout. +/// Belgian PEPPOL tax info provider. Delegates every method to the default PEPPOL30 implementation and, +/// via FinalizeTaxTotals, appends a compensating Exempt (category E) VAT breakdown line for the +/// conditional payment discount (escompte). This keeps VAT on the discounted base (as required in +/// Belgium) while the amount payable stays whole. /// codeunit 37315 "PEPPOL30 BE Tax Info" implements "PEPPOL Tax Info Provider" { @@ -22,6 +23,7 @@ codeunit 37315 "PEPPOL30 BE Tax Info" implements "PEPPOL Tax Info Provider" var PEPPOL30: Codeunit "PEPPOL30"; + Escompte: Codeunit "PEPPOL30 BE Escompte"; procedure GetAllowanceChargeInfo(VATAmtLine: Record "VAT Amount Line"; SalesHeader: Record "Sales Header"; var ChargeIndicator: Text; var AllowanceChargeReasonCode: Text; var AllowanceChargeListID: Text; var AllowanceChargeReason: Text; var Amount: Text; var AllowanceChargeCurrencyID: Text; var TaxCategoryID: Text; var TaxCategorySchemeID: Text; var Percent: Text; var AllowanceChargeTaxSchemeID: Text) begin @@ -55,14 +57,33 @@ codeunit 37315 "PEPPOL30 BE Tax Info" implements "PEPPOL Tax Info Provider" procedure GetTaxTotals(SalesLine: Record "Sales Line"; var VATAmtLine: Record "VAT Amount Line") begin - // In Belgium the payment discount must not reduce the PEPPOL document totals. Zeroing the payment - // discount on the by-value sales line before accumulation keeps TaxableAmount, TaxExclusiveAmount, - // TaxInclusiveAmount and PayableAmount aligned with the invoice printout, and the payment discount - // AllowanceCharge is skipped by its existing zero-amount guard. - SalesLine."Pmt. Discount Amount" := 0; PEPPOL30.GetTaxTotals(SalesLine, VATAmtLine); end; + procedure FinalizeTaxTotals(var VATAmtLine: Record "VAT Amount Line") + var + TotalPmtDiscount: Decimal; + begin + VATAmtLine.Reset(); + VATAmtLine.CalcSums("Pmt. Discount Amount"); + TotalPmtDiscount := VATAmtLine."Pmt. Discount Amount"; + if TotalPmtDiscount = 0 then + exit; + + VATAmtLine.Init(); + VATAmtLine."VAT Identifier" := Escompte.GetCompensationVATIdentifier(); + VATAmtLine."VAT Calculation Type" := VATAmtLine."VAT Calculation Type"::"Normal VAT"; + VATAmtLine.Positive := true; + VATAmtLine."Tax Category" := Escompte.GetExemptTaxCategory(); + VATAmtLine."VAT %" := 0; + VATAmtLine."VAT Base" := TotalPmtDiscount; + VATAmtLine."Amount Including VAT" := TotalPmtDiscount; + VATAmtLine."VAT Amount" := 0; + VATAmtLine."Pmt. Discount Amount" := 0; + VATAmtLine."Invoice Discount Amount" := 0; + VATAmtLine.Insert(); + end; + procedure GetTaxCategories(SalesLine: Record "Sales Line"; var VATProductPostingGroupCategory: Record "VAT Product Posting Group") begin PEPPOL30.GetTaxCategories(SalesLine, VATProductPostingGroupCategory); @@ -71,6 +92,8 @@ codeunit 37315 "PEPPOL30 BE Tax Info" implements "PEPPOL Tax Info Provider" procedure GetTaxExemptionReason(var VATProductPostingGroupCategory: Record "VAT Product Posting Group"; var TaxExemptionReasonTxt: Text; TaxCategoryID: Text) begin PEPPOL30.GetTaxExemptionReason(VATProductPostingGroupCategory, TaxExemptionReasonTxt, TaxCategoryID); + if (TaxExemptionReasonTxt = '') and (TaxCategoryID = Escompte.GetExemptTaxCategory()) then + TaxExemptionReasonTxt := Escompte.GetCompensationExemptionReason(); end; procedure IsZeroVatCategory(TaxCategory: Code[10]): Boolean diff --git a/src/Apps/BE/PeppolBE/App/src/PEPPOL30FormatBE.EnumExt.al b/src/Apps/BE/PeppolBE/App/src/PEPPOL30FormatBE.EnumExt.al index c7a210deaa3..a2379581e0c 100644 --- a/src/Apps/BE/PeppolBE/App/src/PEPPOL30FormatBE.EnumExt.al +++ b/src/Apps/BE/PeppolBE/App/src/PEPPOL30FormatBE.EnumExt.al @@ -13,13 +13,17 @@ enumextension 37310 "PEPPOL 3.0 Format BE" extends "PEPPOL 3.0 Format" Caption = 'PEPPOL 3.0 - Belgium Sales Format'; Implementation = "PEPPOL30 Validation" = "PEPPOL30 BE Sales Validation", "PEPPOL Posted Document Iterator" = "PEPPOL30 Sales Iterator", - "PEPPOL Tax Info Provider" = "PEPPOL30 BE Tax Info"; + "PEPPOL Tax Info Provider" = "PEPPOL30 BE Tax Info", + "PEPPOL Payment Info Provider" = "PEPPOL30 BE Payment Info", + "PEPPOL Monetary Info Provider" = "PEPPOL30 BE Monetary Info"; } value(37311; "PEPPOL 3.0 - BE Service") { Caption = 'PEPPOL 3.0 - Belgium Service Format'; Implementation = "PEPPOL30 Validation" = "PEPPOL30 BE Service Validation", "PEPPOL Posted Document Iterator" = "PEPPOL30 Services Iterator", - "PEPPOL Tax Info Provider" = "PEPPOL30 BE Tax Info"; + "PEPPOL Tax Info Provider" = "PEPPOL30 BE Tax Info", + "PEPPOL Payment Info Provider" = "PEPPOL30 BE Payment Info", + "PEPPOL Monetary Info Provider" = "PEPPOL30 BE Monetary Info"; } } diff --git a/src/Apps/W1/PEPPOL/App/src/Common/PEPPOL30Common.Codeunit.al b/src/Apps/W1/PEPPOL/App/src/Common/PEPPOL30Common.Codeunit.al index a4298162c09..c7b44c0613e 100644 --- a/src/Apps/W1/PEPPOL/App/src/Common/PEPPOL30Common.Codeunit.al +++ b/src/Apps/W1/PEPPOL/App/src/Common/PEPPOL30Common.Codeunit.al @@ -197,6 +197,8 @@ codeunit 37218 "PEPPOL30 Common" else Error(UnsupportedDocumentErr); end; + + PEPPOLTaxInfoProvider.FinalizeTaxTotals(TempVATAmtLine); end; /// diff --git a/src/Apps/W1/PEPPOL/App/src/Interfaces/PEPPOLTaxInfoProvider.Interface.al b/src/Apps/W1/PEPPOL/App/src/Interfaces/PEPPOLTaxInfoProvider.Interface.al index 2512fa9f28a..afec744eda5 100644 --- a/src/Apps/W1/PEPPOL/App/src/Interfaces/PEPPOLTaxInfoProvider.Interface.al +++ b/src/Apps/W1/PEPPOL/App/src/Interfaces/PEPPOLTaxInfoProvider.Interface.al @@ -141,4 +141,13 @@ interface "PEPPOL Tax Info Provider" /// The tax category code to check. /// True if the tax category is outside VAT scope, false otherwise. procedure IsOutsideScopeVATCategory(TaxCategory: Code[10]): Boolean; + + /// + /// Called once per document after all lines have been aggregated into the VAT amount line buffer, + /// letting a format append synthetic VAT breakdown lines if needed. Needed to add, for example, compensation lines. + /// + /// The accumulated VAT amount line buffer to post-process. + procedure FinalizeTaxTotals(var VATAmtLine: Record "VAT Amount Line") + begin + end; } diff --git a/src/Apps/W1/PEPPOL/App/src/Sales/XmlPorts/SalesCrMemoPEPPOL30.XmlPort.al b/src/Apps/W1/PEPPOL/App/src/Sales/XmlPorts/SalesCrMemoPEPPOL30.XmlPort.al index 974f5d06ba1..dacb5c96de3 100644 --- a/src/Apps/W1/PEPPOL/App/src/Sales/XmlPorts/SalesCrMemoPEPPOL30.XmlPort.al +++ b/src/Apps/W1/PEPPOL/App/src/Sales/XmlPorts/SalesCrMemoPEPPOL30.XmlPort.al @@ -1357,6 +1357,12 @@ xmlport 37200 "Sales Cr.Memo - PEPPOL30" { XmlName = 'AllowanceChargeReasonCode'; NamespacePrefix = 'cbc'; + + trigger OnBeforePassVariable() + begin + if AllowanceChargeReasonCodePaymentDiscount = '' then + currXMLport.Skip(); + end; } textelement(AllowanceChargeReasonPaymentDiscount) { diff --git a/src/Apps/W1/PEPPOL/App/src/Sales/XmlPorts/SalesInvoicePEPPOL30.XmlPort.al b/src/Apps/W1/PEPPOL/App/src/Sales/XmlPorts/SalesInvoicePEPPOL30.XmlPort.al index cfa66824cdb..df5560cce5d 100644 --- a/src/Apps/W1/PEPPOL/App/src/Sales/XmlPorts/SalesInvoicePEPPOL30.XmlPort.al +++ b/src/Apps/W1/PEPPOL/App/src/Sales/XmlPorts/SalesInvoicePEPPOL30.XmlPort.al @@ -1308,6 +1308,12 @@ xmlport 37201 "Sales Invoice - PEPPOL30" { XmlName = 'AllowanceChargeReasonCode'; NamespacePrefix = 'cbc'; + + trigger OnBeforePassVariable() + begin + if AllowanceChargeReasonCodePaymentDiscount = '' then + currXMLport.Skip(); + end; } textelement(AllowanceChargeReasonPaymentDiscount) { diff --git a/src/Layers/BE/BaseApp/Sales/Peppol/PEPPOLManagement.Codeunit.al b/src/Layers/BE/BaseApp/Sales/Peppol/PEPPOLManagement.Codeunit.al index c4e3b7a9cf0..b346a8671c0 100644 --- a/src/Layers/BE/BaseApp/Sales/Peppol/PEPPOLManagement.Codeunit.al +++ b/src/Layers/BE/BaseApp/Sales/Peppol/PEPPOLManagement.Codeunit.al @@ -53,6 +53,9 @@ codeunit 1605 "PEPPOL Management" BICTxt: Label 'BIC', Locked = true; AllowanceChargeReasonCodeTxt: Label '104', Locked = true; AllowanceChargePaymentDiscountReasonCodeTxt: Label '95', Locked = true; + PmtDiscCompChargeReasonTxt: Label 'Payment discount not deducted from the amount payable'; + PmtDiscCompExemptionReasonTxt: Label 'Conditional early-payment discount, not part of the taxable amount'; + PmtDiscCompVATIdentifierTxt: Label 'ESCOMPTE-COMP', Locked = true; PaymentMeansFundsTransferCodeTxt: Label '31', Locked = true; GTINTxt: Label '0160', Locked = true; UoMforPieceINUNECERec20ListIDTxt: Label 'EA', Locked = true; @@ -1025,6 +1028,20 @@ codeunit 1605 "PEPPOL Management" /// Returns the tax scheme identifier. procedure GetAllowanceChargeInfoPaymentDiscount(VATAmtLine: Record "VAT Amount Line"; SalesHeader: Record "Sales Header"; var ChargeIndicator: Text; var AllowanceChargeReasonCode: Text; var AllowanceChargeListID: Text; var AllowanceChargeReason: Text; var Amount: Text; var AllowanceChargeCurrencyID: Text; var TaxCategoryID: Text; var TaxCategorySchemeID: Text; var Percent: Text; var AllowanceChargeTaxSchemeID: Text) begin + if IsPmtDiscCompensationLine(VATAmtLine) then begin + ChargeIndicator := 'true'; + AllowanceChargeReasonCode := ''; + AllowanceChargeListID := ''; + AllowanceChargeReason := PmtDiscCompChargeReasonTxt; + Amount := Format(VATAmtLine."VAT Base", 0, 9); + AllowanceChargeCurrencyID := GetSalesDocCurrencyCode(SalesHeader); + TaxCategoryID := VATAmtLine."Tax Category"; + TaxCategorySchemeID := ''; + Percent := Format(VATAmtLine."VAT %", 0, 9); + AllowanceChargeTaxSchemeID := VATTxt; + exit; + end; + if VATAmtLine."Pmt. Discount Amount" = 0 then begin ChargeIndicator := ''; exit; @@ -1210,6 +1227,19 @@ codeunit 1605 "PEPPOL Management" /// Returns the currency code for payable amount. procedure GetLegalMonetaryInfo(SalesHeader: Record "Sales Header"; var TempSalesLine: Record "Sales Line" temporary; var VATAmtLine: Record "VAT Amount Line"; var LineExtensionAmount: Text; var LegalMonetaryTotalCurrencyID: Text; var TaxExclusiveAmount: Text; var TaxExclusiveAmountCurrencyID: Text; var TaxInclusiveAmount: Text; var TaxInclusiveAmountCurrencyID: Text; var AllowanceTotalAmount: Text; var AllowanceTotalAmountCurrencyID: Text; var ChargeTotalAmount: Text; var ChargeTotalAmountCurrencyID: Text; var PrepaidAmount: Text; var PrepaidCurrencyID: Text; var PayableRoundingAmount: Text; var PayableRndingAmountCurrencyID: Text; var PayableAmount: Text; var PayableAmountCurrencyID: Text) begin + if HasPmtDiscCompensationLine(VATAmtLine) then begin + CalcLegalMonetaryInfoWithCompensation( + SalesHeader, TempSalesLine, VATAmtLine, LineExtensionAmount, LegalMonetaryTotalCurrencyID, + TaxExclusiveAmount, TaxExclusiveAmountCurrencyID, TaxInclusiveAmount, TaxInclusiveAmountCurrencyID, + AllowanceTotalAmount, AllowanceTotalAmountCurrencyID, ChargeTotalAmount, ChargeTotalAmountCurrencyID, + PrepaidAmount, PrepaidCurrencyID, PayableRoundingAmount, PayableRndingAmountCurrencyID, + PayableAmount, PayableAmountCurrencyID); + OnAfterGetLegalMonetaryInfoWithInvRounding( + SalesHeader, TempSalesLine, VATAmtLine, LineExtensionAmount, TaxExclusiveAmount, TaxInclusiveAmount, + AllowanceTotalAmount, ChargeTotalAmount, PrepaidAmount, PayableRoundingAmount, PayableAmount); + exit; + end; + VATAmtLine.Reset(); VATAmtLine.CalcSums("Line Amount", "VAT Base", "Amount Including VAT", "Invoice Discount Amount"); @@ -1683,6 +1713,101 @@ codeunit 1605 "PEPPOL Management" end; end; + procedure AddPaymentDiscountCompensation(var VATAmtLine: Record "VAT Amount Line") + var + TotalPmtDiscount: Decimal; + begin + VATAmtLine.Reset(); + VATAmtLine.CalcSums("Pmt. Discount Amount"); + TotalPmtDiscount := VATAmtLine."Pmt. Discount Amount"; + if TotalPmtDiscount = 0 then + exit; + + VATAmtLine.Init(); + VATAmtLine."VAT Identifier" := GetPmtDiscCompensationVATIdentifier(); + VATAmtLine."VAT Calculation Type" := VATAmtLine."VAT Calculation Type"::"Normal VAT"; + VATAmtLine.Positive := true; + VATAmtLine."Tax Category" := CopyStr(GetTaxCategoryE(), 1, MaxStrLen(VATAmtLine."Tax Category")); + VATAmtLine."VAT %" := 0; + VATAmtLine."VAT Base" := TotalPmtDiscount; + VATAmtLine."Amount Including VAT" := TotalPmtDiscount; + VATAmtLine."VAT Amount" := 0; + VATAmtLine."Pmt. Discount Amount" := 0; + VATAmtLine."Invoice Discount Amount" := 0; + VATAmtLine.Insert(); + end; + + local procedure GetPmtDiscCompensationVATIdentifier(): Code[20] + begin + exit(CopyStr(PmtDiscCompVATIdentifierTxt, 1, 20)); + end; + + local procedure IsPmtDiscCompensationLine(VATAmtLine: Record "VAT Amount Line"): Boolean + begin + exit(VATAmtLine."VAT Identifier" = GetPmtDiscCompensationVATIdentifier()); + end; + + local procedure HasPmtDiscCompensationLine(var VATAmtLine: Record "VAT Amount Line"): Boolean + var + Found: Boolean; + begin + VATAmtLine.Reset(); + VATAmtLine.SetRange("VAT Identifier", GetPmtDiscCompensationVATIdentifier()); + Found := not VATAmtLine.IsEmpty(); + VATAmtLine.Reset(); + exit(Found); + end; + + local procedure CalcLegalMonetaryInfoWithCompensation(SalesHeader: Record "Sales Header"; var TempSalesLine: Record "Sales Line" temporary; var VATAmtLine: Record "VAT Amount Line"; var LineExtensionAmount: Text; var LegalMonetaryTotalCurrencyID: Text; var TaxExclusiveAmount: Text; var TaxExclusiveAmountCurrencyID: Text; var TaxInclusiveAmount: Text; var TaxInclusiveAmountCurrencyID: Text; var AllowanceTotalAmount: Text; var AllowanceTotalAmountCurrencyID: Text; var ChargeTotalAmount: Text; var ChargeTotalAmountCurrencyID: Text; var PrepaidAmount: Text; var PrepaidCurrencyID: Text; var PayableRoundingAmount: Text; var PayableRndingAmountCurrencyID: Text; var PayableAmount: Text; var PayableAmountCurrencyID: Text) + var + CompensationAmount: Decimal; + RealVATBase: Decimal; + RealInvDiscount: Decimal; + RealPmtDiscount: Decimal; + RealAmtInclVAT: Decimal; + CurrencyId: Text; + begin + VATAmtLine.Reset(); + if VATAmtLine.FindSet() then + repeat + if IsPmtDiscCompensationLine(VATAmtLine) then + CompensationAmount += VATAmtLine."VAT Base" + else begin + RealVATBase += VATAmtLine."VAT Base"; + RealInvDiscount += VATAmtLine."Invoice Discount Amount"; + RealPmtDiscount += VATAmtLine."Pmt. Discount Amount"; + RealAmtInclVAT += VATAmtLine."Amount Including VAT"; + end; + until VATAmtLine.Next() = 0; + + CurrencyId := GetSalesDocCurrencyCode(SalesHeader); + + LineExtensionAmount := Format(Round(RealVATBase, 0.01) + Round(RealInvDiscount, 0.01), 0, 9); + LegalMonetaryTotalCurrencyID := CurrencyId; + TaxExclusiveAmount := Format(Round(RealVATBase - RealPmtDiscount + CompensationAmount, 0.01), 0, 9); + TaxExclusiveAmountCurrencyID := CurrencyId; + TaxInclusiveAmount := Format(Round(RealAmtInclVAT - RealPmtDiscount + CompensationAmount, 0.01, '>'), 0, 9); + TaxInclusiveAmountCurrencyID := CurrencyId; + AllowanceTotalAmount := Format(Round(RealInvDiscount + RealPmtDiscount, 0.01), 0, 9); + AllowanceTotalAmountCurrencyID := CurrencyId; + ChargeTotalAmount := Format(Round(CompensationAmount, 0.01), 0, 9); + ChargeTotalAmountCurrencyID := CurrencyId; + PrepaidAmount := '0.00'; + PrepaidCurrencyID := CurrencyId; + + if TempSalesLine."Line No." = 0 then begin + PayableRoundingAmount := Format(RealAmtInclVAT - Round(RealAmtInclVAT, 0.01), 0, 9); + PayableRndingAmountCurrencyID := CurrencyId; + PayableAmount := Format(Round(RealAmtInclVAT - RealPmtDiscount + CompensationAmount, 0.01), 0, 9); + PayableAmountCurrencyID := CurrencyId; + end else begin + PayableRoundingAmount := Format(TempSalesLine."Amount Including VAT", 0, 9); + PayableRndingAmountCurrencyID := CurrencyId; + PayableAmount := Format(Round(RealAmtInclVAT + TempSalesLine."Amount Including VAT" - RealPmtDiscount + CompensationAmount, 0.01), 0, 9); + PayableAmountCurrencyID := CurrencyId; + end; + end; + /// /// Retrieves and accumulates tax categories from sales lines into a buffer. /// @@ -1733,6 +1858,8 @@ codeunit 1605 "PEPPOL Management" exit; if VATProductPostingGroupCategory.Get(TaxCategoryID) then TaxExemptionReasonTxt := VATProductPostingGroupCategory.Description; + if (TaxExemptionReasonTxt = '') and (TaxCategoryID = GetTaxCategoryE()) then + TaxExemptionReasonTxt := PmtDiscCompExemptionReasonTxt; end; /// diff --git a/src/Layers/BE/BaseApp/Sales/Peppol/SalesInvoicePEPPOLBIS30.XmlPort.al b/src/Layers/BE/BaseApp/Sales/Peppol/SalesInvoicePEPPOLBIS30.XmlPort.al index cac089e0e46..82fd95fb3bd 100644 --- a/src/Layers/BE/BaseApp/Sales/Peppol/SalesInvoicePEPPOLBIS30.XmlPort.al +++ b/src/Layers/BE/BaseApp/Sales/Peppol/SalesInvoicePEPPOLBIS30.XmlPort.al @@ -1267,6 +1267,12 @@ xmlport 1610 "Sales Invoice - PEPPOL BIS 3.0" { XmlName = 'AllowanceChargeReasonCode'; NamespacePrefix = 'cbc'; + + trigger OnBeforePassVariable() + begin + if AllowanceChargeReasonCodePaymentDiscount = '' then + currXMLport.Skip(); + end; } textelement(AllowanceChargeReasonPaymentDiscount) { @@ -2185,6 +2191,8 @@ xmlport 1610 "Sales Invoice - PEPPOL BIS 3.0" else OnGetTotals(SourceRecRef, SalesLine, TempVATAmtLine, TempVATProductPostingGroup, ProcessedDocType); end; + + PEPPOLMgt.AddPaymentDiscountCompensation(TempVATAmtLine); end; local procedure FindNextInvoiceRec(Position: Integer) Found: Boolean From e6ed9545348a8f19644f7b8c079ce11235046292 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Joshua=20Mart=C3=ADnez=20Pineda?= Date: Thu, 3 Sep 2026 17:31:48 +0200 Subject: [PATCH 2/4] tests --- src/Apps/BE/PeppolBE/Test/app.json | 48 ++++++++++++++++ .../PEPPOL30BEPmtDiscountTests.Codeunit.al | 55 +++++++++++++------ 2 files changed, 86 insertions(+), 17 deletions(-) create mode 100644 src/Apps/BE/PeppolBE/Test/app.json diff --git a/src/Apps/BE/PeppolBE/Test/app.json b/src/Apps/BE/PeppolBE/Test/app.json new file mode 100644 index 00000000000..dc7ec56b364 --- /dev/null +++ b/src/Apps/BE/PeppolBE/Test/app.json @@ -0,0 +1,48 @@ +{ + "id": "140aecf2-70e7-4ea8-93ee-78dd27659bee", + "name": "PEPPOL BE Tests", + "publisher": "Microsoft", + "version": "29.0.0.0", + "brief": "Tests for the PEPPOL BIS 3.0 BE localization.", + "description": "Tests for the PEPPOL BIS 3.0 BE localization.", + "privacyStatement": "https://go.microsoft.com/fwlink/?LinkId=724009", + "EULA": "https://go.microsoft.com/fwlink/?linkid=2009120", + "help": "https://go.microsoft.com/fwlink/?linkid=2104024", + "contextSensitiveHelpUrl": "https://go.microsoft.com/fwlink/?linkid=2206603", + "url": "https://go.microsoft.com/fwlink/?LinkId=724011", + "dependencies": [ + { + "id": "5d86850b-0d76-4eca-bd7b-951ad998e997", + "name": "Tests-TestLibraries", + "publisher": "Microsoft", + "version": "29.0.0.0" + }, + { + "id": "e1966889-b5fb-4fda-a84c-ea71b590e1a9", + "name": "PEPPOL", + "publisher": "Microsoft", + "version": "29.0.0.0" + }, + { + "id": "e56642d4-5b38-4de1-8cf4-b2ed9513971a", + "name": "PEPPOL BE", + "publisher": "Microsoft", + "version": "29.0.0.0" + } + ], + "screenshots": [], + "platform": "29.0.0.0", + "application": "29.0.0.0", + "idRanges": [ + { + "from": 148720, + "to": 148729 + } + ], + "resourceExposurePolicy": { + "allowDebugging": true, + "allowDownloadingSource": true, + "includeSourceInSymbolFile": true + }, + "target": "OnPrem" +} diff --git a/src/Apps/BE/PeppolBE/Test/src/PEPPOL30BEPmtDiscountTests.Codeunit.al b/src/Apps/BE/PeppolBE/Test/src/PEPPOL30BEPmtDiscountTests.Codeunit.al index 0bc6e3a79b8..89f814713b1 100644 --- a/src/Apps/BE/PeppolBE/Test/src/PEPPOL30BEPmtDiscountTests.Codeunit.al +++ b/src/Apps/BE/PeppolBE/Test/src/PEPPOL30BEPmtDiscountTests.Codeunit.al @@ -38,17 +38,17 @@ codeunit 148720 "PEPPOL30 BE Pmt Disc Tests" LibraryTestInitialize: Codeunit "Library - Test Initialize"; IsInitialized: Boolean; InvoiceNamespaceTxt: Label 'urn:oasis:names:specification:ubl:schema:xsd:Invoice-2', Locked = true; + EscompteExemptionReasonTxt: Label 'Conditional early-payment discount, not part of the taxable amount'; [Test] - procedure PaymentDiscountNotDeductedFromTaxAmountsForBESalesInvoice() + procedure BESalesInvoiceEscompteCompensation() var SalesInvoiceHeader: Record "Sales Invoice Header"; TempBlob: Codeunit "Temp Blob"; CustomerNo: Code[20]; PaymentTermsCode: Code[10]; begin - // [SCENARIO 643204] For the Belgian PEPPOL format the taxable amount is calculated on the full amount, - // i.e. the payment discount is NOT deducted from the VAT-taxable base, so the XML matches the invoice printout. + // [SCENARIO 643204] The Belgian escompte keeps VAT on the discounted base, but the conditional payment discount must not reduce the amount payable. Initialize(); // [GIVEN] Payment Terms with a 3% payment discount @@ -56,23 +56,39 @@ codeunit 148720 "PEPPOL30 BE Pmt Disc Tests" // [GIVEN] A customer that uses those payment terms CustomerNo := CreateCustomerWithAddressAndGLN(); - // [GIVEN] A posted sales invoice for 1 x 111.20 EUR with 21% VAT and the 3% payment discount terms + // [GIVEN] A posted sales invoice for 1 x 111.20 with 21% VAT and the 3% payment discount terms; the escompte + // is active so VAT is charged on the discounted base 107.86 (VAT 22.65, total 133.85). PostSalesInvoiceWithPmtDiscount(SalesInvoiceHeader, CustomerNo, PaymentTermsCode, 111.2, 21); // [WHEN] The posted invoice is exported to PEPPOL BIS 3.0 using the Belgian sales format SalesInvoiceHeader.SetRecFilter(); ExportInvoiceToBlob(SalesInvoiceHeader, TempBlob); - - // [THEN] The taxable/monetary totals are calculated on the full amount (111.20 / 134.55), - // and NOT reduced by the payment discount (which would give 107.86 / 131.21 and fail BR-S-08). InitXPathXMLReaderForInvoice(TempBlob); + + // [THEN] Two VAT breakdowns: Standard 107.86 / 22.65 and the compensating Exempt 3.34 / 0.00 (with a reason) + LibraryXPathXMLReader.VerifyNodeCountByXPath('//cac:TaxTotal/cac:TaxSubtotal', 2); + LibraryXPathXMLReader.VerifyNodeValueByXPath('//cac:TaxTotal/cbc:TaxAmount', '22.65'); + LibraryXPathXMLReader.VerifyNodeValueByXPath('//cac:TaxSubtotal[cac:TaxCategory/cbc:ID=''S'']/cbc:TaxableAmount', '107.86'); + LibraryXPathXMLReader.VerifyNodeValueByXPath('//cac:TaxSubtotal[cac:TaxCategory/cbc:ID=''S'']/cbc:TaxAmount', '22.65'); + LibraryXPathXMLReader.VerifyNodeValueByXPath('//cac:TaxSubtotal[cac:TaxCategory/cbc:ID=''E'']/cbc:TaxableAmount', '3.34'); + LibraryXPathXMLReader.VerifyNodeValueByXPath('//cac:TaxSubtotal[cac:TaxCategory/cbc:ID=''E'']/cbc:TaxAmount', '0.00'); + LibraryXPathXMLReader.VerifyNodeValueByXPath('//cac:TaxSubtotal[cac:TaxCategory/cbc:ID=''E'']/cac:TaxCategory/cbc:TaxExemptionReason', EscompteExemptionReasonTxt); + + // [THEN] Two document-level AllowanceCharges: the Standard payment-discount allowance and the Exempt compensating charge + LibraryXPathXMLReader.VerifyNodeCountByXPath('//cac:AllowanceCharge', 2); + LibraryXPathXMLReader.VerifyNodeValueByXPath('//cac:AllowanceCharge[cbc:ChargeIndicator=''false'']/cbc:Amount', '3.34'); + LibraryXPathXMLReader.VerifyNodeValueByXPath('//cac:AllowanceCharge[cbc:ChargeIndicator=''true'']/cbc:Amount', '3.34'); + LibraryXPathXMLReader.VerifyNodeValueByXPath('//cac:AllowanceCharge[cbc:ChargeIndicator=''true'']/cac:TaxCategory/cbc:ID', 'E'); + // [THEN] The compensating charge carries only a text reason - no (empty) reason code element is emitted + LibraryXPathXMLReader.VerifyNodeCountByXPath('//cac:AllowanceCharge[cbc:ChargeIndicator=''true'']/cbc:AllowanceChargeReasonCode', 0); + + // [THEN] The amount payable stays whole: LineExtension/TaxExclusive 111.20, Allowance & Charge 3.34, total 133.85 LibraryXPathXMLReader.VerifyNodeValueByXPath('//cac:LegalMonetaryTotal/cbc:LineExtensionAmount', '111.2'); LibraryXPathXMLReader.VerifyNodeValueByXPath('//cac:LegalMonetaryTotal/cbc:TaxExclusiveAmount', '111.2'); - LibraryXPathXMLReader.VerifyNodeValueByXPath('//cac:LegalMonetaryTotal/cbc:TaxInclusiveAmount', '134.55'); - LibraryXPathXMLReader.VerifyNodeValueByXPath('//cac:LegalMonetaryTotal/cbc:PayableAmount', '134.55'); - // [THEN] The tax subtotal taxable amount equals the full amount and no payment-discount allowance is emitted - LibraryXPathXMLReader.VerifyNodeValueByXPath('//cac:TaxTotal/cac:TaxSubtotal/cbc:TaxableAmount', '111.2'); - LibraryXPathXMLReader.VerifyNodeAbsence('//cac:AllowanceCharge'); + LibraryXPathXMLReader.VerifyNodeValueByXPath('//cac:LegalMonetaryTotal/cbc:AllowanceTotalAmount', '3.34'); + LibraryXPathXMLReader.VerifyNodeValueByXPath('//cac:LegalMonetaryTotal/cbc:ChargeTotalAmount', '3.34'); + LibraryXPathXMLReader.VerifyNodeValueByXPath('//cac:LegalMonetaryTotal/cbc:TaxInclusiveAmount', '133.85'); + LibraryXPathXMLReader.VerifyNodeValueByXPath('//cac:LegalMonetaryTotal/cbc:PayableAmount', '133.85'); end; local procedure Initialize() @@ -100,6 +116,9 @@ codeunit 148720 "PEPPOL30 BE Pmt Disc Tests" CompanyInformation."VAT Registration No." := LibraryERM.GenerateVATRegistrationNo(CompanyInformation."Country/Region Code"); CompanyInformation.Validate(GLN, '1234567891231'); CompanyInformation.Validate("Use GLN in Electronic Document", true); + CompanyInformation."Bank Account No." := '1234567890'; + CompanyInformation."Bank Branch No." := '1234'; + CompanyInformation."SWIFT Code" := 'GEBABEBB'; CompanyInformation.Modify(true); LibraryERMCountryData.CreateVATData(); @@ -108,7 +127,7 @@ codeunit 148720 "PEPPOL30 BE Pmt Disc Tests" LibraryERMCountryData.UpdateSalesReceivablesSetup(); LibraryERMCountryData.UpdateLocalData(); - EnableAdjustForPaymentDiscount(); + EnableBEPaymentDiscountVAT(); LibrarySetupStorage.Save(Database::"Company Information"); LibrarySetupStorage.Save(Database::"General Ledger Setup"); @@ -128,13 +147,16 @@ codeunit 148720 "PEPPOL30 BE Pmt Disc Tests" PEPPOLSetup.Modify(); end; - local procedure EnableAdjustForPaymentDiscount() + local procedure EnableBEPaymentDiscountVAT() var GeneralLedgerSetup: Record "General Ledger Setup"; begin + // Belgian escompte: VAT is charged on the discounted base. GeneralLedgerSetup.Get(); - GeneralLedgerSetup."Adjust for Payment Disc." := true; - GeneralLedgerSetup.Modify(); + GeneralLedgerSetup.Validate("Adjust for Payment Disc.", false); + GeneralLedgerSetup.Validate("Pmt. Disc. Excl. VAT", true); + GeneralLedgerSetup.Validate("VAT Tolerance %", 3); + GeneralLedgerSetup.Modify(true); end; local procedure CreatePaymentTermsWithDiscount(DiscountPct: Decimal): Code[10] @@ -208,7 +230,6 @@ codeunit 148720 "PEPPOL30 BE Pmt Disc Tests" VATPostingSetup.Validate("VAT Calculation Type", VATPostingSetup."VAT Calculation Type"::"Normal VAT"); VATPostingSetup.Validate("VAT %", VATPct); VATPostingSetup.Validate("Tax Category", 'S'); - VATPostingSetup."Adjust for Payment Discount" := true; VATPostingSetup.Validate("Sales VAT Account", LibraryERM.CreateGLAccountNo()); VATPostingSetup.Modify(true); exit(VATProductPostingGroup.Code); From 976fac1ea5a9113a21df00b178798c46e6e256e6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Joshua=20Mart=C3=ADnez=20Pineda?= Date: Tue, 8 Sep 2026 13:34:27 +0200 Subject: [PATCH 3/4] feedback: GetTaxExemptionReason with extra information to determine whether a line is a escompte compensation --- .../App/src/PEPPOL30BETaxInfo.Codeunit.al | 10 +- .../PEPPOL30BEPmtDiscountTests.Codeunit.al | 113 +++++++++++++++++- .../PEPPOLTaxInfoProvider.Interface.al | 13 ++ .../XmlPorts/SalesCrMemoPEPPOL30.XmlPort.al | 2 +- .../XmlPorts/SalesInvoicePEPPOL30.XmlPort.al | 2 +- 5 files changed, 134 insertions(+), 6 deletions(-) diff --git a/src/Apps/BE/PeppolBE/App/src/PEPPOL30BETaxInfo.Codeunit.al b/src/Apps/BE/PeppolBE/App/src/PEPPOL30BETaxInfo.Codeunit.al index 068c2d4e3c0..5a1481cc64a 100644 --- a/src/Apps/BE/PeppolBE/App/src/PEPPOL30BETaxInfo.Codeunit.al +++ b/src/Apps/BE/PeppolBE/App/src/PEPPOL30BETaxInfo.Codeunit.al @@ -92,8 +92,16 @@ codeunit 37315 "PEPPOL30 BE Tax Info" implements "PEPPOL Tax Info Provider" procedure GetTaxExemptionReason(var VATProductPostingGroupCategory: Record "VAT Product Posting Group"; var TaxExemptionReasonTxt: Text; TaxCategoryID: Text) begin PEPPOL30.GetTaxExemptionReason(VATProductPostingGroupCategory, TaxExemptionReasonTxt, TaxCategoryID); - if (TaxExemptionReasonTxt = '') and (TaxCategoryID = Escompte.GetExemptTaxCategory()) then + end; + + procedure GetTaxExemptionReason(VATAmtLine: Record "VAT Amount Line"; var VATProductPostingGroupCategory: Record "VAT Product Posting Group"; var TaxExemptionReasonTxt: Text; TaxCategoryID: Text) + begin + if Escompte.IsCompensationLine(VATAmtLine) then begin TaxExemptionReasonTxt := Escompte.GetCompensationExemptionReason(); + exit; + end; + + GetTaxExemptionReason(VATProductPostingGroupCategory, TaxExemptionReasonTxt, TaxCategoryID); end; procedure IsZeroVatCategory(TaxCategory: Code[10]): Boolean diff --git a/src/Apps/BE/PeppolBE/Test/src/PEPPOL30BEPmtDiscountTests.Codeunit.al b/src/Apps/BE/PeppolBE/Test/src/PEPPOL30BEPmtDiscountTests.Codeunit.al index 89f814713b1..98ebd780468 100644 --- a/src/Apps/BE/PeppolBE/Test/src/PEPPOL30BEPmtDiscountTests.Codeunit.al +++ b/src/Apps/BE/PeppolBE/Test/src/PEPPOL30BEPmtDiscountTests.Codeunit.al @@ -39,6 +39,8 @@ codeunit 148720 "PEPPOL30 BE Pmt Disc Tests" IsInitialized: Boolean; InvoiceNamespaceTxt: Label 'urn:oasis:names:specification:ubl:schema:xsd:Invoice-2', Locked = true; EscompteExemptionReasonTxt: Label 'Conditional early-payment discount, not part of the taxable amount'; + ExemptionReasonXPathTxt: Label '//cac:TaxTotal/cac:TaxSubtotal/cac:TaxCategory/cbc:TaxExemptionReason', Locked = true; + GenuineExemptionReasonTxt: Label 'Exempt under article 44', Locked = true; [Test] procedure BESalesInvoiceEscompteCompensation() @@ -91,6 +93,69 @@ codeunit 148720 "PEPPOL30 BE Pmt Disc Tests" LibraryXPathXMLReader.VerifyNodeValueByXPath('//cac:LegalMonetaryTotal/cbc:PayableAmount', '133.85'); end; + [Test] + procedure BESalesInvoiceExemptLineKeepsOwnExemptionReason() + var + SalesInvoiceHeader: Record "Sales Invoice Header"; + TempBlob: Codeunit "Temp Blob"; + CustomerNo: Code[20]; + PaymentTermsCode: Code[10]; + begin + // [SCENARIO 643204] A genuinely exempt VAT breakdown keeps its own exemption reason when the escompte compensation is present. + Initialize(); + + // [GIVEN] Payment Terms with a 3% payment discount and a customer that uses them + PaymentTermsCode := CreatePaymentTermsWithDiscount(3); + CustomerNo := CreateCustomerWithAddressAndGLN(); + + // [GIVEN] A posted sales invoice with a 21% line and an exempt (category E) line whose VAT product posting group is described + PostSalesInvoice( + SalesInvoiceHeader, CustomerNo, PaymentTermsCode, + CreateVATPostingSetupWithPmtDiscount(GetVATBusPostingGroup(CustomerNo), 21), 111.2, + CreateExemptVATPostingSetup(GetVATBusPostingGroup(CustomerNo), GenuineExemptionReasonTxt), 100); + + // [WHEN] The posted invoice is exported to PEPPOL BIS 3.0 using the Belgian sales format + SalesInvoiceHeader.SetRecFilter(); + ExportInvoiceToBlob(SalesInvoiceHeader, TempBlob); + InitXPathXMLReaderForInvoice(TempBlob); + + // [THEN] Three VAT breakdowns are written: Standard, the genuine Exempt one and the compensating Exempt one + LibraryXPathXMLReader.VerifyNodeCountByXPath('//cac:TaxTotal/cac:TaxSubtotal', 3); + + // [THEN] The escompte reason is used once, and the genuine exempt breakdown keeps the reason from its VAT product posting group + LibraryXPathXMLReader.VerifyNodeCountWithValueByXPath(ExemptionReasonXPathTxt, EscompteExemptionReasonTxt, 1); + LibraryXPathXMLReader.VerifyNodeCountWithValueByXPath(ExemptionReasonXPathTxt, GenuineExemptionReasonTxt, 1); + end; + + [Test] + procedure BESalesInvoiceWithoutPmtDiscountHasNoEscompteReason() + var + SalesInvoiceHeader: Record "Sales Invoice Header"; + TempBlob: Codeunit "Temp Blob"; + CustomerNo: Code[20]; + begin + // [SCENARIO 643204] An exempt VAT breakdown without an exemption reason is not given the escompte reason when there is no payment discount. + Initialize(); + + // [GIVEN] A customer without payment discount terms + CustomerNo := CreateCustomerWithAddressAndGLN(); + + // [GIVEN] A posted sales invoice with a single exempt (category E) line whose VAT product posting group has no description + PostSalesInvoice( + SalesInvoiceHeader, CustomerNo, '', + CreateExemptVATPostingSetup(GetVATBusPostingGroup(CustomerNo), ''), 100, + '', 0); + + // [WHEN] The posted invoice is exported to PEPPOL BIS 3.0 using the Belgian sales format + SalesInvoiceHeader.SetRecFilter(); + ExportInvoiceToBlob(SalesInvoiceHeader, TempBlob); + InitXPathXMLReaderForInvoice(TempBlob); + + // [THEN] The single Exempt breakdown is not given the escompte exemption reason + LibraryXPathXMLReader.VerifyNodeCountByXPath('//cac:TaxTotal/cac:TaxSubtotal', 1); + LibraryXPathXMLReader.VerifyNodeCountWithValueByXPath(ExemptionReasonXPathTxt, EscompteExemptionReasonTxt, 0); + end; + local procedure Initialize() var CompanyInformation: Record "Company Information"; @@ -200,9 +265,15 @@ codeunit 148720 "PEPPOL30 BE Pmt Disc Tests" end; local procedure PostSalesInvoiceWithPmtDiscount(var SalesInvoiceHeader: Record "Sales Invoice Header"; CustomerNo: Code[20]; PaymentTermsCode: Code[10]; UnitPrice: Decimal; VATPct: Decimal) + begin + PostSalesInvoice( + SalesInvoiceHeader, CustomerNo, PaymentTermsCode, + CreateVATPostingSetupWithPmtDiscount(GetVATBusPostingGroup(CustomerNo), VATPct), UnitPrice, '', 0); + end; + + local procedure PostSalesInvoice(var SalesInvoiceHeader: Record "Sales Invoice Header"; CustomerNo: Code[20]; PaymentTermsCode: Code[10]; FirstVATProdPostingGroup: Code[20]; FirstUnitPrice: Decimal; SecondVATProdPostingGroup: Code[20]; SecondUnitPrice: Decimal) var SalesHeader: Record "Sales Header"; - SalesLine: Record "Sales Line"; begin LibrarySales.CreateSalesHeader(SalesHeader, SalesHeader."Document Type"::Invoice, CustomerNo); SalesHeader.Validate("Payment Terms Code", PaymentTermsCode); @@ -210,13 +281,49 @@ codeunit 148720 "PEPPOL30 BE Pmt Disc Tests" SalesHeader.Validate("Your Reference", LibraryUtility.GenerateGUID()); SalesHeader.Modify(true); + AddSalesLine(SalesHeader, FirstVATProdPostingGroup, FirstUnitPrice); + if SecondVATProdPostingGroup <> '' then + AddSalesLine(SalesHeader, SecondVATProdPostingGroup, SecondUnitPrice); + + SalesInvoiceHeader.Get(LibrarySales.PostSalesDocument(SalesHeader, true, true)); + end; + + local procedure AddSalesLine(var SalesHeader: Record "Sales Header"; VATProdPostingGroup: Code[20]; UnitPrice: Decimal) + var + SalesLine: Record "Sales Line"; + begin LibrarySales.CreateSalesLine( SalesLine, SalesHeader, SalesLine.Type::"G/L Account", LibraryERM.CreateGLAccountWithSalesSetup(), 1); - SalesLine.Validate("VAT Prod. Posting Group", CreateVATPostingSetupWithPmtDiscount(SalesHeader."VAT Bus. Posting Group", VATPct)); + SalesLine.Validate("VAT Prod. Posting Group", VATProdPostingGroup); SalesLine.Validate("Unit Price", UnitPrice); SalesLine.Modify(true); + end; - SalesInvoiceHeader.Get(LibrarySales.PostSalesDocument(SalesHeader, true, true)); + local procedure GetVATBusPostingGroup(CustomerNo: Code[20]): Code[20] + var + Customer: Record Customer; + begin + Customer.Get(CustomerNo); + exit(Customer."VAT Bus. Posting Group"); + end; + + local procedure CreateExemptVATPostingSetup(VATBusPostingGroup: Code[20]; ProductPostingGroupDescription: Text[100]): Code[20] + var + VATPostingSetup: Record "VAT Posting Setup"; + VATProductPostingGroup: Record "VAT Product Posting Group"; + begin + LibraryERM.CreateVATProductPostingGroup(VATProductPostingGroup); + VATProductPostingGroup.Validate(Description, ProductPostingGroupDescription); + VATProductPostingGroup.Modify(true); + + LibraryERM.CreateVATPostingSetup(VATPostingSetup, VATBusPostingGroup, VATProductPostingGroup.Code); + VATPostingSetup."VAT Identifier" := LibraryUtility.GenerateGUID(); + VATPostingSetup.Validate("VAT Calculation Type", VATPostingSetup."VAT Calculation Type"::"Normal VAT"); + VATPostingSetup.Validate("VAT %", 0); + VATPostingSetup.Validate("Tax Category", 'E'); + VATPostingSetup.Validate("Sales VAT Account", LibraryERM.CreateGLAccountNo()); + VATPostingSetup.Modify(true); + exit(VATProductPostingGroup.Code); end; local procedure CreateVATPostingSetupWithPmtDiscount(VATBusPostingGroup: Code[20]; VATPct: Decimal): Code[20] diff --git a/src/Apps/W1/PEPPOL/App/src/Interfaces/PEPPOLTaxInfoProvider.Interface.al b/src/Apps/W1/PEPPOL/App/src/Interfaces/PEPPOLTaxInfoProvider.Interface.al index afec744eda5..3f923df2310 100644 --- a/src/Apps/W1/PEPPOL/App/src/Interfaces/PEPPOLTaxInfoProvider.Interface.al +++ b/src/Apps/W1/PEPPOL/App/src/Interfaces/PEPPOLTaxInfoProvider.Interface.al @@ -150,4 +150,17 @@ interface "PEPPOL Tax Info Provider" procedure FinalizeTaxTotals(var VATAmtLine: Record "VAT Amount Line") begin end; + + /// + /// Gets the tax exemption reason text for a given VAT breakdown. Unlike the overload without the VAT amount line, + /// this lets a format tell apart breakdowns that share the same tax category. + /// + /// The VAT amount line the tax subtotal is written from. + /// The VAT product posting group category record. + /// Returns the tax exemption reason text. + /// The tax category ID to get exemption reason for. + procedure GetTaxExemptionReason(VATAmtLine: Record "VAT Amount Line"; var VATProductPostingGroupCategory: Record "VAT Product Posting Group"; var TaxExemptionReasonTxt: Text; TaxCategoryID: Text) + begin + this.GetTaxExemptionReason(VATProductPostingGroupCategory, TaxExemptionReasonTxt, TaxCategoryID); + end; } diff --git a/src/Apps/W1/PEPPOL/App/src/Sales/XmlPorts/SalesCrMemoPEPPOL30.XmlPort.al b/src/Apps/W1/PEPPOL/App/src/Sales/XmlPorts/SalesCrMemoPEPPOL30.XmlPort.al index dacb5c96de3..078185236f5 100644 --- a/src/Apps/W1/PEPPOL/App/src/Sales/XmlPorts/SalesCrMemoPEPPOL30.XmlPort.al +++ b/src/Apps/W1/PEPPOL/App/src/Sales/XmlPorts/SalesCrMemoPEPPOL30.XmlPort.al @@ -1529,7 +1529,7 @@ xmlport 37200 "Sales Cr.Memo - PEPPOL30" TaxCategoryPercent, TaxTotalTaxSchemeID); - PEPPOLTaxInfoProvider.GetTaxExemptionReason(TempVATProductPostingGroup, TaxExemptionReason, TaxTotalTaxCategoryID); + PEPPOLTaxInfoProvider.GetTaxExemptionReason(TempVATAmtLine, TempVATProductPostingGroup, TaxExemptionReason, TaxTotalTaxCategoryID); end; } diff --git a/src/Apps/W1/PEPPOL/App/src/Sales/XmlPorts/SalesInvoicePEPPOL30.XmlPort.al b/src/Apps/W1/PEPPOL/App/src/Sales/XmlPorts/SalesInvoicePEPPOL30.XmlPort.al index df5560cce5d..aad98473a85 100644 --- a/src/Apps/W1/PEPPOL/App/src/Sales/XmlPorts/SalesInvoicePEPPOL30.XmlPort.al +++ b/src/Apps/W1/PEPPOL/App/src/Sales/XmlPorts/SalesInvoicePEPPOL30.XmlPort.al @@ -1480,7 +1480,7 @@ xmlport 37201 "Sales Invoice - PEPPOL30" TaxCategoryPercent, TaxTotalTaxSchemeID); - PEPPOLTaxInfoProvider.GetTaxExemptionReason(TempVATProductPostingGroup, TaxExemptionReason, TaxTotalTaxCategoryID); + PEPPOLTaxInfoProvider.GetTaxExemptionReason(TempVATAmtLine, TempVATProductPostingGroup, TaxExemptionReason, TaxTotalTaxCategoryID); end; } From 762e263eafa46569c5b79be32e1dfa9cfccc804c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Joshua=20Mart=C3=ADnez=20Pineda?= Date: Tue, 8 Sep 2026 13:40:14 +0200 Subject: [PATCH 4/4] analogous fix for baseapp side --- .../Sales/Peppol/PEPPOLManagement.Codeunit.al | 18 +++++++++++++++++- .../Peppol/SalesCrMemoPEPPOLBIS30.XmlPort.al | 2 +- .../Peppol/SalesInvoicePEPPOLBIS30.XmlPort.al | 2 +- 3 files changed, 19 insertions(+), 3 deletions(-) diff --git a/src/Layers/BE/BaseApp/Sales/Peppol/PEPPOLManagement.Codeunit.al b/src/Layers/BE/BaseApp/Sales/Peppol/PEPPOLManagement.Codeunit.al index b346a8671c0..8960699c51c 100644 --- a/src/Layers/BE/BaseApp/Sales/Peppol/PEPPOLManagement.Codeunit.al +++ b/src/Layers/BE/BaseApp/Sales/Peppol/PEPPOLManagement.Codeunit.al @@ -1858,8 +1858,24 @@ codeunit 1605 "PEPPOL Management" exit; if VATProductPostingGroupCategory.Get(TaxCategoryID) then TaxExemptionReasonTxt := VATProductPostingGroupCategory.Description; - if (TaxExemptionReasonTxt = '') and (TaxCategoryID = GetTaxCategoryE()) then + end; + + /// + /// Retrieves the tax exemption reason for a specific VAT breakdown. Unlike the overload without the VAT amount line, + /// this tells apart breakdowns that share the same tax category, such as the payment discount compensation. + /// + /// Specifies the VAT amount line the tax subtotal is written from. + /// Specifies the VAT product posting group category buffer. + /// Returns the tax exemption reason description. + /// Specifies the tax category identifier to look up. + procedure GetTaxExemptionReason(VATAmtLine: Record "VAT Amount Line"; var VATProductPostingGroupCategory: Record "VAT Product Posting Group"; var TaxExemptionReasonTxt: Text; TaxCategoryID: Text) + begin + if IsPmtDiscCompensationLine(VATAmtLine) then begin TaxExemptionReasonTxt := PmtDiscCompExemptionReasonTxt; + exit; + end; + + GetTaxExemptionReason(VATProductPostingGroupCategory, TaxExemptionReasonTxt, TaxCategoryID); end; /// diff --git a/src/Layers/BE/BaseApp/Sales/Peppol/SalesCrMemoPEPPOLBIS30.XmlPort.al b/src/Layers/BE/BaseApp/Sales/Peppol/SalesCrMemoPEPPOLBIS30.XmlPort.al index 9becaa029d8..950c86f09e2 100644 --- a/src/Layers/BE/BaseApp/Sales/Peppol/SalesCrMemoPEPPOLBIS30.XmlPort.al +++ b/src/Layers/BE/BaseApp/Sales/Peppol/SalesCrMemoPEPPOLBIS30.XmlPort.al @@ -1478,7 +1478,7 @@ xmlport 1611 "Sales Cr.Memo - PEPPOL BIS 3.0" TaxCategoryPercent, TaxTotalTaxSchemeID); - PEPPOLMgt.GetTaxExemptionReason(TempVATProductPostingGroup, TaxExemptionReason, TaxTotalTaxCategoryID); + PEPPOLMgt.GetTaxExemptionReason(TempVATAmtLine, TempVATProductPostingGroup, TaxExemptionReason, TaxTotalTaxCategoryID); end; } diff --git a/src/Layers/BE/BaseApp/Sales/Peppol/SalesInvoicePEPPOLBIS30.XmlPort.al b/src/Layers/BE/BaseApp/Sales/Peppol/SalesInvoicePEPPOLBIS30.XmlPort.al index 82fd95fb3bd..0e056a95193 100644 --- a/src/Layers/BE/BaseApp/Sales/Peppol/SalesInvoicePEPPOLBIS30.XmlPort.al +++ b/src/Layers/BE/BaseApp/Sales/Peppol/SalesInvoicePEPPOLBIS30.XmlPort.al @@ -1434,7 +1434,7 @@ xmlport 1610 "Sales Invoice - PEPPOL BIS 3.0" TaxCategoryPercent, TaxTotalTaxSchemeID); - PEPPOLMgt.GetTaxExemptionReason(TempVATProductPostingGroup, TaxExemptionReason, TaxTotalTaxCategoryID); + PEPPOLMgt.GetTaxExemptionReason(TempVATAmtLine, TempVATProductPostingGroup, TaxExemptionReason, TaxTotalTaxCategoryID); end; }