Codeunit 1620 PEPPOL Validation, source in 29

Source29

src/Layers/W1/BaseApp/Sales/Peppol/PEPPOLValidation.Codeunit.al493 lines, Copyright (c) Microsoft Corporation. MIT

#if not CLEAN29
// ------------------------------------------------------------------------------------------------
// Copyright (c) Microsoft Corporation. All rights reserved.
// Licensed under the MIT License. See License.txt in the project root for license information.
// ------------------------------------------------------------------------------------------------
namespace Microsoft.Sales.Peppol;

using Microsoft.Finance.AllocationAccount;
using Microsoft.Finance.Currency;
using Microsoft.Finance.GeneralLedger.Setup;
using Microsoft.Finance.VAT.Setup;
using Microsoft.Foundation.Address;
using Microsoft.Foundation.Company;
using Microsoft.Inventory.Location;
using Microsoft.Sales.Customer;
using Microsoft.Sales.Document;
using Microsoft.Sales.History;
using System.Utilities;

/// <summary>
/// Validates sales documents against PEPPOL requirements before electronic invoice export.
/// </summary>
codeunit 1620 "PEPPOL Validation"
{
    TableNo = "Sales Header";
    ObsoleteState = Pending;
    ObsoleteReason = 'Replaced by PEPPOL App';
    ObsoleteTag = '29.0';

    trigger OnRun()
    var
        IsHandled: Boolean;
    begin
        IsHandled := false;
        OnBeforeOnRun(Rec, IsHandled);
        if IsHandled then
            exit;

        CheckSalesDocument(Rec);
        CheckSalesDocumentLines(Rec);
    end;

    var
        ConfirmManagement: Codeunit "Confirm Management";
        OutsideScopeVATBreakdowns: Dictionary of [Text, Text];
#pragma warning disable AA0470
        WrongLengthErr: Label 'should be %1 characters long';
        EmptyUnitOfMeasureErr: Label 'You must specify a valid International Standard Code for the Unit of Measure for %1.', Comment = 'Parameter 1 - document type (Quote,Order,Invoice,Credit Memo,Blanket Order,Return Order), 2 - document number';
#pragma warning restore AA0470
        MissingDescriptionErr: Label 'Description field is empty. \Field must be filled if you want to send the posted document as an electronic document.', Comment = 'Parameter 1 - document type (), 2 - document number';
#pragma warning disable AA0470
        MissingCustGLNOrVATRegNoErr: Label 'You must specify either GLN or VAT Registration No. for Customer %1.';
#pragma warning restore AA0470
        MissingCompInfGLNOrVATRegNoErr: Label 'You must specify either GLN or VAT Registration No. in %1.', Comment = '%1=Company Information';
        NegativeUnitPriceErr: Label 'The unit price is negative in %1. It cannot be negative if you want to send the posted document as an electronic document. \\Do you want to continue?', Comment = '%1 - record ID';
        VatMustBeZeroForCategoryErr: Label 'VAT % must be 0 for tax category code %1', Comment = '%1 - Tax Category code';
        OnlyOneOCategoryVatPostingSetupErr: Label 'There can be only one tax subtotal present on invoice used with "Not subject to VAT" (O) tax category.';
        VATGreaterThanZeroErr: Label 'Line should have greater VAT than 0% for tax category %1', Comment = '%1 - Tax Category code';

    /// <summary>
    /// Validates a sales document header against PEPPOL requirements including company information, customer data, and required fields.
    /// </summary>
    /// <param name="SalesHeader">Specifies the sales document header to validate.</param>
    procedure CheckSalesDocument(SalesHeader: Record "Sales Header")
    var
        CompanyInfo: Record "Company Information";
        GLSetup: Record "General Ledger Setup";
        ResponsibilityCenter: Record "Responsibility Center";
        Customer: Record Customer;
        IsHandled: Boolean;
    begin
        CompanyInfo.Get();
        GLSetup.Get();

        IsHandled := false;
        OnBeforeCheckSalesDocument(SalesHeader, CompanyInfo, IsHandled);
        if IsHandled then
            exit;

        CheckCurrencyCode(SalesHeader."Currency Code");

        if SalesHeader."Responsibility Center" <> '' then begin
            ResponsibilityCenter.Get(SalesHeader."Responsibility Center");
            ResponsibilityCenter.TestField(Name);
            ResponsibilityCenter.TestField(Address);
            ResponsibilityCenter.TestField(City);
            ResponsibilityCenter.TestField("Post Code");
            ResponsibilityCenter.TestField("Country/Region Code");
        end else begin
            CompanyInfo.TestField(Name);
            CompanyInfo.TestField(Address);
            CompanyInfo.TestField(City);
            CompanyInfo.TestField("Post Code");
        end;

        CompanyInfo.TestField("Country/Region Code");
        CheckCountryRegionCode(CompanyInfo."Country/Region Code");

        IsHandled := false;
        OnCheckSalesDocumentOnBeforeCheckCompanyVATRegNo(SalesHeader, CompanyInfo, IsHandled);
        if not IsHandled then
            if CompanyInfo.GLN + CompanyInfo."VAT Registration No." = '' then
                Error(MissingCompInfGLNOrVATRegNoErr, CompanyInfo.TableCaption());
        SalesHeader.TestField("Bill-to Name");
        SalesHeader.TestField("Bill-to Address");
        SalesHeader.TestField("Bill-to City");
        SalesHeader.TestField("Bill-to Post Code");
        SalesHeader.TestField("Bill-to Country/Region Code");
        CheckCountryRegionCode(SalesHeader."Bill-to Country/Region Code");

        IsHandled := false;
        OnCheckSalesDocumentOnBeforeCheckCustomerVATRegNo(SalesHeader, Customer, IsHandled);
        if not IsHandled then
            if (SalesHeader."Document Type" in [SalesHeader."Document Type"::Invoice, SalesHeader."Document Type"::Order, SalesHeader."Document Type"::"Credit Memo"]) and
               Customer.Get(SalesHeader."Bill-to Customer No.")
            then
                if (Customer.GLN + Customer."VAT Registration No.") = '' then
                    Error(MissingCustGLNOrVATRegNoErr, Customer."No.");

        if SalesHeader."Document Type" = SalesHeader."Document Type"::"Credit Memo" then
            if SalesHeader."Applies-to Doc. Type" = SalesHeader."Applies-to Doc. Type"::Invoice then
                SalesHeader.TestField("Applies-to Doc. No.");

        if SalesHeader."Document Type" in [SalesHeader."Document Type"::Invoice, SalesHeader."Document Type"::Order] then
            SalesHeader.TestField("Shipment Date");

        IsHandled := false;
        OnCheckSalesDocumentOnBeforeCheckYourReference(SalesHeader, IsHandled);
        if not IsHandled then
            SalesHeader.TestField("Your Reference");

        CheckShipToAddress(SalesHeader);
        SalesHeader.TestField("Due Date");

        if CompanyInfo.IBAN = '' then
            CompanyInfo.TestField("Bank Account No.");
        CompanyInfo.TestField("Bank Branch No.");
        CompanyInfo.TestField("SWIFT Code");

        OnAfterCheckSalesDocument(SalesHeader, CompanyInfo);
    end;

    /// <summary>
    /// Validates all lines of a sales document against PEPPOL requirements.
    /// </summary>
    /// <param name="SalesHeader">Specifies the sales document header whose lines are to be validated.</param>
    procedure CheckSalesDocumentLines(SalesHeader: Record "Sales Header")
    var
        SalesLine: Record "Sales Line";
    begin
        SalesLine.SetRange("Document Type", SalesHeader."Document Type");
        SalesLine.SetRange("Document No.", SalesHeader."No.");
        if SalesLine.FindSet() then
            repeat
                CheckSalesDocumentLine(SalesLine)
            until SalesLine.Next() = 0;
    end;

    /// <summary>
    /// Validates a single sales document line against PEPPOL requirements including unit of measure codes, descriptions, and VAT settings.
    /// </summary>
    /// <param name="SalesLine">Specifies the sales document line to validate.</param>
    procedure CheckSalesDocumentLine(SalesLine: Record "Sales Line")
    var
        GeneralLedgerSetup: Record "General Ledger Setup";
        PEPPOLMgt: Codeunit "PEPPOL Management";
        unitCode: Text;
        unitCodeListID: Text;
        IsHandled: Boolean;
    begin
        IsHandled := false;
        OnBeforeCheckSalesDocumentLine(SalesLine, IsHandled);
        if IsHandled then
            exit;

        // Allocation account lines are placeholder lines that are expanded into their underlying G/L
        // distribution lines during posting and are never themselves exported in the electronic
        // document. Validate the G/L accounts they will be expanded into instead.
        if SalesLine.Type = SalesLine.Type::"Allocation Account" then begin
            this.CheckAllocationAccountExpandedLines(SalesLine);
            exit;
        end;

        PEPPOLMgt.GetLineUnitCodeInfo(SalesLine, unitCode, unitCodeListID);
        if (SalesLine.Type <> SalesLine.Type::" ") and (SalesLine."No." <> '') and (unitCode = '') then
            Error(EmptyUnitOfMeasureErr, SalesLine."Unit of Measure Code");

        IsHandled := false;
        OnCheckSalesDocumentLineOnBeforeCheckEmptyDescription(SalesLine, IsHandled);
        if not IsHandled then
            if CheckSalesLineTypeAndDescription(SalesLine) then
                Error(MissingDescriptionErr);

        if (SalesLine.Type <> SalesLine.Type::" ") and (SalesLine."No." <> '') then begin
            // Not a description line
            if GeneralLedgerSetup.UseVat() then
                SalesLine.TestField("VAT Prod. Posting Group");
            this.CheckTaxCategory(SalesLine);

            if (SalesLine.Type = SalesLine.Type::Item) and (SalesLine."Unit Price" < 0) then
                if not ConfirmManagement.GetResponseOrDefault(StrSubstNo(NegativeUnitPriceErr, SalesLine.RecordId), false) then
                    Error('');
        end;
    end;

    local procedure CheckAllocationAccountExpandedLines(SalesLine: Record "Sales Line")
    var
        AllocationAccount: Record "Allocation Account";
        AllocationLine: Record "Allocation Line";
        ExpandedSalesLine: Record "Sales Line";
        AllocationAccountMgt: Codeunit "Allocation Account Mgt.";
    begin
        if not AllocationAccount.Get(SalesLine."No.") then
            exit;

        AllocationAccountMgt.GenerateAllocationLines(
            AllocationAccount, AllocationLine, SalesLine.Amount,
            SalesLine.GetSalesHeader()."Posting Date", SalesLine."Dimension Set ID", SalesLine."Currency Code");

        if AllocationLine.FindSet() then
            repeat
                if AllocationLine."Destination Account Type" = AllocationLine."Destination Account Type"::"G/L Account" then begin
                    ExpandedSalesLine := SalesLine;
                    ExpandedSalesLine.Type := ExpandedSalesLine.Type::"G/L Account";
                    ExpandedSalesLine."No." := AllocationLine."Destination Account Number";
                    this.CheckSalesDocumentLine(ExpandedSalesLine);
                end;
            until AllocationLine.Next() = 0;
    end;

    /// <summary>
    /// Validates a posted sales invoice and all its lines against PEPPOL requirements.
    /// </summary>
    /// <param name="SalesInvoiceHeader">Specifies the posted sales invoice header to validate.</param>
    procedure CheckSalesInvoice(SalesInvoiceHeader: Record "Sales Invoice Header")
    var
        SalesInvoiceLine: Record "Sales Invoice Line";
        SalesHeader: Record "Sales Header";
        SalesLine: Record "Sales Line";
    begin
        SalesHeader.TransferFields(SalesInvoiceHeader);
        SalesHeader."Document Type" := SalesHeader."Document Type"::Invoice;
        CheckSalesDocument(SalesHeader);
        SalesInvoiceLine.SetRange("Document No.", SalesInvoiceHeader."No.");
        if SalesInvoiceLine.FindSet() then
            repeat
                SalesLine.TransferFields(SalesInvoiceLine);
                SalesLine."Document Type" := SalesLine."Document Type"::Invoice;
                CheckSalesDocumentLine(SalesLine);
            until SalesInvoiceLine.Next() = 0;
    end;

    /// <summary>
    /// Validates a posted sales credit memo and all its lines against PEPPOL requirements.
    /// </summary>
    /// <param name="SalesCrMemoHeader">Specifies the posted sales credit memo header to validate.</param>
    procedure CheckSalesCreditMemo(SalesCrMemoHeader: Record "Sales Cr.Memo Header")
    var
        SalesCrMemoLine: Record "Sales Cr.Memo Line";
        SalesHeader: Record "Sales Header";
        SalesLine: Record "Sales Line";
    begin
        SalesHeader.TransferFields(SalesCrMemoHeader);
        SalesHeader."Document Type" := SalesHeader."Document Type"::"Credit Memo";
        CheckSalesDocument(SalesHeader);
        SalesCrMemoLine.SetRange("Document No.", SalesCrMemoHeader."No.");
        if SalesCrMemoLine.FindSet() then
            repeat
                SalesLine.TransferFields(SalesCrMemoLine);
                SalesLine."Document Type" := SalesLine."Document Type"::"Credit Memo";
                CheckSalesDocumentLine(SalesLine);
            until SalesCrMemoLine.Next() = 0;
    end;




    local procedure CheckCurrencyCode(CurrencyCode: Code[10])
    var
        GLSetup: Record "General Ledger Setup";
        Currency: Record Currency;
        MaxCurrencyCodeLength: Integer;
    begin
        MaxCurrencyCodeLength := 3;

        if CurrencyCode = '' then begin
            GLSetup.Get();
            GLSetup.TestField("LCY Code");
            CurrencyCode := GLSetup."LCY Code";
        end;

        if not Currency.Get(CurrencyCode) then begin
            if StrLen(CurrencyCode) <> MaxCurrencyCodeLength then
                GLSetup.FieldError("LCY Code", StrSubstNo(WrongLengthErr, MaxCurrencyCodeLength));
            exit; // Valid
        end;

        if StrLen(Currency.Code) <> MaxCurrencyCodeLength then
            Currency.FieldError(Code, StrSubstNo(WrongLengthErr, MaxCurrencyCodeLength));
    end;

    local procedure CheckCountryRegionCode(CountryRegionCode: Code[10])
    var
        CountryRegion: Record "Country/Region";
        CompanyInfo: Record "Company Information";
        MaxCountryCodeLength: Integer;
    begin
        MaxCountryCodeLength := 2;

        if CountryRegionCode = '' then begin
            CompanyInfo.Get();
            CompanyInfo.TestField("Country/Region Code");
            CountryRegionCode := CompanyInfo."Country/Region Code";
        end;

        CountryRegion.Get(CountryRegionCode);
        CountryRegion.TestField("ISO Code");
        if StrLen(CountryRegion."ISO Code") <> MaxCountryCodeLength then
            CountryRegion.FieldError("ISO Code", StrSubstNo(WrongLengthErr, MaxCountryCodeLength));
    end;

    local procedure CheckShipToAddress(SalesHeader: Record "Sales Header")
    var
        IsHandled: Boolean;
    begin
        IsHandled := false;
        OnBeforeCheckShipToAddress(SalesHeader, IsHandled);
        if IsHandled then
            exit;

        SalesHeader.TestField("Ship-to Address");
        SalesHeader.TestField("Ship-to City");
        SalesHeader.TestField("Ship-to Post Code");
        SalesHeader.TestField("Ship-to Country/Region Code");
        CheckCountryRegionCode(SalesHeader."Ship-to Country/Region Code");
    end;

    local procedure CheckTaxCategory(SalesLine: Record "Sales Line")
    var
        VATPostingSetup: Record "VAT Posting Setup";
        PEPPOLManagement: Codeunit "PEPPOL Management";
    begin
        VATPostingSetup.Get(SalesLine."VAT Bus. Posting Group", SalesLine."VAT Prod. Posting Group");
        VATPostingSetup.TestField("Tax Category");

        case true of
            PEPPOLManagement.IsStandardVATCategory(VATPostingSetup."Tax Category"):
                this.EnsurePositiveRate(SalesLine."VAT %", VATPostingSetup."Tax Category");
            PEPPOLManagement.IsOutsideScopeVATCategory(VATPostingSetup."Tax Category"):
                begin
                    this.EnsureZeroRate(SalesLine."VAT %", VATPostingSetup."Tax Category");
                    this.EnsureSingleOutsideScopeVATBreakdown(SalesLine);
                end;
            PEPPOLManagement.IsZeroVatCategory(VATPostingSetup."Tax Category"):
                this.EnsureZeroRate(SalesLine."VAT %", VATPostingSetup."Tax Category");
        end
    end;

    local procedure EnsureZeroRate(VatPercent: Decimal; TaxCategoryCode: Code[10])
    begin
        if VatPercent > 0 then
            Error(VatMustBeZeroForCategoryErr, TaxCategoryCode);
    end;

    local procedure EnsurePositiveRate(VatPercent: Decimal; TaxCategoryCode: Code[10])
    begin
        if VatPercent = 0 then
            Error(VATGreaterThanZeroErr, TaxCategoryCode);
    end;

    local procedure EnsureSingleOutsideScopeVATBreakdown(SalesLine: Record "Sales Line")
    var
        BreakdownKey: Text;
    begin
        // Check if separate VAT amount line won't be created to ensure that only one VAT breakdown line is created in PEPPOL document
        BreakdownKey := Format(SalesLine."VAT Calculation Type") + '|' + SalesLine."Tax Group Code";

        if OutsideScopeVATBreakdowns.Count() > 0 then begin
            if not OutsideScopeVATBreakdowns.ContainsKey(BreakdownKey) then
                Error(OnlyOneOCategoryVatPostingSetupErr);
        end else
            OutsideScopeVATBreakdowns.Add(BreakdownKey, Format(SalesLine."VAT %"));
    end;

    /// <summary>
    /// Checks if a sales line has a type set but is missing a description.
    /// </summary>
    /// <param name="SalesLine">Specifies the sales line to check.</param>
    /// <returns>Returns true if the line has a type but no description, false otherwise.</returns>
    procedure CheckSalesLineTypeAndDescription(SalesLine: Record "Sales Line"): Boolean
    begin
        if (SalesLine.Type <> SalesLine.Type::" ") and (SalesLine.Description = '') then
            exit(true);
    end;

    /// <summary>
    /// Raised after validating a sales document header against PEPPOL requirements.
    /// </summary>
    /// <param name="SalesHeader">Specifies the sales header record that was validated.</param>
    /// <param name="CompanyInfo">Specifies the company information record used for validation.</param>
    [IntegrationEvent(false, false)]
    local procedure OnAfterCheckSalesDocument(SalesHeader: Record "Sales Header"; CompanyInfo: Record "Company Information")
    begin
    end;

    /// <summary>
    /// Raised before validating the ship-to address of a sales document.
    /// </summary>
    /// <param name="SalesHeader">Specifies the sales header record to validate.</param>
    /// <param name="IsHandled">Set to true to skip the default ship-to address validation.</param>
    [IntegrationEvent(false, false)]
    local procedure OnBeforeCheckShipToAddress(SalesHeader: Record "Sales Header"; var IsHandled: Boolean)
    begin
    end;

    /// <summary>
    /// Raised before running the PEPPOL validation codeunit.
    /// </summary>
    /// <param name="SalesHeader">Specifies the sales header record to validate.</param>
    /// <param name="IsHandled">Set to true to skip the default validation logic.</param>
    [IntegrationEvent(false, false)]
    local procedure OnBeforeOnRun(SalesHeader: Record "Sales Header"; var IsHandled: Boolean)
    begin
    end;

    /// <summary>
    /// Raised before checking if a sales line has an empty description.
    /// </summary>
    /// <param name="SalesLine">Specifies the sales line record being validated.</param>
    /// <param name="IsHandled">Set to true to skip the empty description check.</param>
    [IntegrationEvent(false, false)]
    local procedure OnCheckSalesDocumentLineOnBeforeCheckEmptyDescription(SalesLine: Record "Sales Line"; var IsHandled: Boolean)
    begin
    end;

    /// <summary>
    /// Raised before validating a sales document header against PEPPOL requirements.
    /// </summary>
    /// <param name="SalesHeader">Specifies the sales header record to validate.</param>
    /// <param name="CompanyInformation">Specifies the company information record used for validation.</param>
    /// <param name="IsHandled">Set to true to skip the default sales document validation.</param>
    [IntegrationEvent(false, false)]
    local procedure OnBeforeCheckSalesDocument(var SalesHeader: Record "Sales Header"; var CompanyInformation: Record "Company Information"; var IsHandled: Boolean)
    begin
    end;

    /// <summary>
    /// Raised before validating a sales document line against PEPPOL requirements.
    /// </summary>
    /// <param name="SalesLine">Specifies the sales line record to validate.</param>
    /// <param name="IsHandled">Set to true to skip the default sales line validation.</param>
    [IntegrationEvent(false, false)]
    local procedure OnBeforeCheckSalesDocumentLine(var SalesLine: Record "Sales Line"; var IsHandled: Boolean)
    begin
    end;




    /// <summary>
    /// Raised before checking the company VAT registration number during sales document validation.
    /// </summary>
    /// <param name="SalesHeader">Specifies the sales header record being validated.</param>
    /// <param name="CompanyInformation">Specifies the company information record.</param>
    /// <param name="IsHandled">Set to true to skip the company VAT registration number check.</param>
    [IntegrationEvent(false, false)]
    local procedure OnCheckSalesDocumentOnBeforeCheckCompanyVATRegNo(SalesHeader: Record "Sales Header"; CompanyInformation: Record "Company Information"; var IsHandled: Boolean)
    begin
    end;

    /// <summary>
    /// Raised before checking the customer VAT registration number during sales document validation.
    /// </summary>
    /// <param name="SalesHeader">Specifies the sales header record being validated.</param>
    /// <param name="Customer">Specifies the customer record.</param>
    /// <param name="IsHandled">Set to true to skip the customer VAT registration number check.</param>
    [IntegrationEvent(false, false)]
    local procedure OnCheckSalesDocumentOnBeforeCheckCustomerVATRegNo(SalesHeader: Record "Sales Header"; Customer: Record Customer; var IsHandled: Boolean)
    begin
    end;

    /// <summary>
    /// Raised before checking the Your Reference field during sales document validation.
    /// </summary>
    /// <param name="SalesHeader">Specifies the sales header record being validated.</param>
    /// <param name="IsHandled">Set to true to skip the Your Reference field check.</param>
    [IntegrationEvent(false, false)]
    local procedure OnCheckSalesDocumentOnBeforeCheckYourReference(SalesHeader: Record "Sales Header"; var IsHandled: Boolean)
    begin
    end;
}
#endif