Codeunit 380 Bank Acc. Recon. Test

App
Base Application
Namespace
Microsoft.Bank.Reconciliation
Versions
20-28

Procedures, 9

Versions171819202122232425262728

Source29

Source in 29

src/Layers/W1/BaseApp/Bank/Reconciliation/BankAccReconTest.Codeunit.al296 lines, Copyright (c) Microsoft Corporation. MIT

// ------------------------------------------------------------------------------------------------
// Copyright (c) Microsoft Corporation. All rights reserved.
// Licensed under the MIT License. See License.txt in the project root for license information.
// ------------------------------------------------------------------------------------------------
namespace Microsoft.Bank.Reconciliation;

using Microsoft.Bank.BankAccount;
using Microsoft.Bank.Ledger;
using Microsoft.Bank.Statement;
using Microsoft.Finance.Currency;
using Microsoft.Finance.GeneralLedger.Account;
using Microsoft.Finance.GeneralLedger.Ledger;

/// <summary>
/// Provides validation and testing functions for bank account reconciliation procedures.
/// Validates reconciliation balance calculations and data integrity before posting.
/// </summary>
codeunit 380 "Bank Acc. Recon. Test"
{

    trigger OnRun()
    begin
    end;

    procedure TotalPositiveDifference(BankAccReconciliation: Record "Bank Acc. Reconciliation"): Decimal
    begin
        case BankAccReconciliation."Statement Type" of
            BankAccReconciliation."Statement Type"::"Bank Reconciliation":
                exit(0);
            BankAccReconciliation."Statement Type"::"Payment Application":
                exit(BankAccReconciliation."Total Positive Adjustments");
        end;
    end;

    procedure TotalNegativeDifference(BankAccReconciliation: Record "Bank Acc. Reconciliation"): Decimal
    begin
        case BankAccReconciliation."Statement Type" of
            BankAccReconciliation."Statement Type"::"Bank Reconciliation":
                exit(0);
            BankAccReconciliation."Statement Type"::"Payment Application":
                exit(BankAccReconciliation."Total Negative Adjustments");
        end;
    end;

    procedure SetOutstandingFilters(BankAccReconciliation: Record "Bank Acc. Reconciliation"; var BankAccountLedgerEntry: Record "Bank Account Ledger Entry")
    begin
        BankAccountLedgerEntry.SetRange("Bank Account No.", BankAccReconciliation."Bank Account No.");
        BankAccountLedgerEntry.SetRange(Reversed, false);
        if BankAccReconciliation."Statement Date" <> 0D then
            BankAccountLedgerEntry.SetRange("Posting Date", 0D, BankAccReconciliation."Statement Date");
        BankAccountLedgerEntry.SetFilter("Statement No.", '<> %1', BankAccReconciliation."Statement No.");
    end;

    procedure CheckBankAccountLedgerEntryFilters(var BankAccountLedgerEntry: Record "Bank Account Ledger Entry"; StatementNo: Code[20]; StatementDate: Date): Boolean
    begin
        if BankAccountLedgerEntry."Statement No." = '' then begin
            if CheckBankLedgerEntryIsOpen(BankAccountLedgerEntry, StatementDate) then
                exit(true);
        end else
            if CheckBankLedgerEntryOnStatement(BankAccountLedgerEntry, StatementDate) then
                exit(true);
        exit(false);
    end;

    local procedure CheckBankLedgerEntryOnStatement(var BankAccountLedgerEntry: Record "Bank Account Ledger Entry"; StatementDate: Date): Boolean
    var
        BankAccountReconciliation: Record "Bank Acc. Reconciliation";
    begin
        // Check if the Bank Account Ledger Entry is closed on a later statement
        if BankAccountLedgerEntry."Closed at Date" > StatementDate then
            exit(true);

        if not BankAccountLedgerEntry.Open then
            exit(false);

        if BankAccountLedgerEntry."Statement Status" = BankAccountLedgerEntry."Statement Status"::Closed then
            exit(false);

        if not BankAccountReconciliation.Get(BankAccountReconciliation."Statement Type"::"Bank Reconciliation", BankAccountLedgerEntry."Bank Account No.", BankAccountLedgerEntry."Statement No.") then
            exit(false);

        exit(BankAccountReconciliation."Statement Date" > StatementDate);
    end;

    local procedure CheckBankLedgerEntryIsOpen(var BankAccountLedgerEntry: Record "Bank Account Ledger Entry"; StatementDate: Date): Boolean
    begin
        //there are closed entries with "statement status" = closed but with blank statement no.
        if BankAccountLedgerEntry."Statement Status" = BankAccountLedgerEntry."Statement Status"::Closed then
            exit(false);
        if BankAccountLedgerEntry.Open then
            exit(true);
        if (BankAccountLedgerEntry."Closed at Date" = 0D) then
            exit(true);
        if BankAccountLedgerEntry."Closed at Date" > StatementDate then
            exit(true);
    end;

    procedure TotalOutstandingBankTransactions(BankAccReconciliation: Record "Bank Acc. Reconciliation"): Decimal
    var
        BankAccountLedgerEntry: Record "Bank Account Ledger Entry";
    begin
        BankAccountLedgerEntry.SetRange("Check Ledger Entries", 0);
        exit(GetTotalOutstanding(BankAccReconciliation, BankAccountLedgerEntry));
    end;

    procedure TotalOutstandingPayments(BankAccReconciliation: Record "Bank Acc. Reconciliation"): Decimal
    var
        BankAccountLedgerEntry: Record "Bank Account Ledger Entry";
    begin
        BankAccountLedgerEntry.SetFilter("Check Ledger Entries", '<> %1', 0);
        exit(GetTotalOutstanding(BankAccReconciliation, BankAccountLedgerEntry));
    end;

    /// <summary>
    /// Gets the total outstanding amount for the given Bank Acc. Reconciliation, it considers the filters applied on the Bank Account Ledger Entry record passed as parameter (that are not overridden).
    /// The calculation avoids looping through the closed Bank Account Ledger Entries via sums and set operations, this is important for performance and it should be maintained like that since it's used at posting.
    /// </summary>
    /// <param name="BankAccReconciliation">The bank account reconciliation</param>
    /// <param name="BankAccountLedgerEntry">Record with the filters to consider throughout the calculation, several get overriden currently it's only meant to respect the "Check Ledger Entries" filter</param>
    /// <returns></returns>
    local procedure GetTotalOutstanding(BankAccReconciliation: Record "Bank Acc. Reconciliation"; var BankAccountLedgerEntry: Record "Bank Account Ledger Entry"): Decimal
    var
        GeneralBankAccountLedgerEntryFilters: Record "Bank Account Ledger Entry";
        BankAccReconciliationLine: Record "Bank Acc. Reconciliation Line";
        x1, x2, x3, x4, x5, x6, x7, Total : Decimal;
        DocNo: Text;
    begin
        // Common filters for all cases
        BankAccountLedgerEntry.SetRange("Bank Account No.", BankAccReconciliation."Bank Account No.");
        BankAccountLedgerEntry.SetRange(Reversed, false);
        if BankAccReconciliation."Statement Date" <> 0D then
            BankAccountLedgerEntry.SetRange("Posting Date", 0D, BankAccReconciliation."Statement Date");

        GeneralBankAccountLedgerEntryFilters.CopyFilters(BankAccountLedgerEntry);
        // Case 1: When Statement No. = ''
        // When we have Statement No = '' it means those BLE that are not matched to any statement yet.
        // This means either they are open and not matched to any ongoing bank rec.
        // We add all BLE that are considered open, or that have been closed after the statement date. The BLEs to add are those that satisfy:
        // 
        //  Statement Status <> Closed
        //  AND (Open = true // case 1.a - this avoids considering corrupted entries with Open = false and Closed at Date = 0D (found in some NA environments)
        //      OR Closed at Date > Statement Date of the bank rec. // case 1.b
        //  )
        //  Note for case 1.b: In principle the precondition Statement No = '' implies Closed at Date = 0D, but in case we have a data inconsistency with:
        //  - Statement No = ''
        //  - Open = false
        //  - Closed at Date > 0D
        //  We will consider it as closed, but for some reason the Statement No. is blank. We will consider the entry only if it was closed after the statement date (since it is considered open at the time of the statement date).
        //
        // We calculate amount for case 1:
        // To calculate possible overlaps between (case 1.a) and (case 1.b) we calculate the intersections and subtract them from the union to avoid double counting

        // General filters for case 1:
        BankAccountLedgerEntry.SetRange("Statement No.", '');
        BankAccountLedgerEntry.SetFilter("Statement Status", '%1 | %2 | %3', BankAccountLedgerEntry."Statement Status"::Open, BankAccountLedgerEntry."Statement Status"::"Bank Acc. Entry Applied", BankAccountLedgerEntry."Statement Status"::"Check Entry Applied");

        // total for case 1.a
        ClearOpenFilters(BankAccountLedgerEntry);
        BankAccountLedgerEntry.SetRange(Open, true);
        BankAccountLedgerEntry.CalcSums(Amount);
        x1 := BankAccountLedgerEntry.Amount;

        // total for case 1.b
        ClearOpenFilters(BankAccountLedgerEntry);
        BankAccountLedgerEntry.SetFilter("Closed at Date", '> %1', BankAccReconciliation."Statement Date");
        BankAccountLedgerEntry.CalcSums(Amount);
        x2 := BankAccountLedgerEntry.Amount;

        // total for (case 1.a) intersection (case 1.b), ideally should be 0, but in case of data inconsistencies we consider it
        ClearOpenFilters(BankAccountLedgerEntry);
        BankAccountLedgerEntry.SetRange(Open, true);
        BankAccountLedgerEntry.SetFilter("Closed at Date", '> %1', BankAccReconciliation."Statement Date");
        BankAccountLedgerEntry.CalcSums(Amount);
        x3 := BankAccountLedgerEntry.Amount;

        // total for case 1
        x4 := x1 + x2 - x3;

        // Case 2: Statement no. <> '' and Statement no. <> BankAccReconciliation."Statement No."
        // These are BLEs that are matched to other statements or to ongoing bank recs (not the current one).
        //
        // Case 2.a: BLEs with Closed at Date <> 0D
        // We will consider as outstanding if they were closed after the statement date of the current bank rec.
        // So we will consider those BLEs that satisfy 
        //
        // Closed at Date > Statement Date of the bank rec.
        //      (disregarding Open, or Statement Status, since we consider them closed when the "Closed At" date is specified):

        // General filters for case 2:
        BankAccountLedgerEntry.SetFilter("Statement No.", '<> %1 & <> %2', BankAccReconciliation."Statement No.", '');
        BankAccountLedgerEntry.SetRange("Statement Status");

        // total for case 2.a
        ClearOpenFilters(BankAccountLedgerEntry);
        BankAccountLedgerEntry.SetFilter("Closed at Date", '> %1', BankAccReconciliation."Statement Date");
        BankAccountLedgerEntry.CalcSums(Amount);
        x5 := BankAccountLedgerEntry.Amount;

        // Case 2.b: BLEs that are open (Closed at Date = 0D)
        // We will consider as outstanding in this case BLEs that are in different ongoing bank recs (not yet closed) as long as they are matched in an statement with a date after the statement date of the current bank rec.
        // If there is any of the BLE fields that signal that the BLE is not open (e.g., Statement Status = Closed or Open = false) we will disregard the BLE.
        // Note that (case 2.a) and (case 2.b) are disjoint

        // total for case 2.b
        ClearOpenFilters(BankAccountLedgerEntry);
        BankAccountLedgerEntry.SetRange("Closed at Date", 0D);
        BankAccountLedgerEntry.SetRange(Open, true); // this also avoids considering corrupted entries with Open = false and Closed at Date = 0D (found in some NA environments)
        BankAccountLedgerEntry.SetFilter("Statement Status", '%1 | %2 | %3', BankAccountLedgerEntry."Statement Status"::Open, BankAccountLedgerEntry."Statement Status"::"Bank Acc. Entry Applied", BankAccountLedgerEntry."Statement Status"::"Check Entry Applied");
        BankAccountLedgerEntry.SetFilter("Statement Date", '>%1', BankAccReconciliation."Statement Date");
        BankAccountLedgerEntry.CalcSums(Amount);
        x6 := BankAccountLedgerEntry.Amount;

        // total for case 2
        x7 := x5 + x6;

        // Final total = case 1 + case 2 (disjoint cases)
        Total := x7 + x4;

        // Adjustments for Payment Application type
        if BankAccReconciliation."Statement Type" = BankAccReconciliation."Statement Type"::"Payment Application" then begin
            // When the BankAccReconciliation is created from the Payment Reconciliation Journal:
            // we subtract the "Applied Amount" to Bank Ledger Entries with no CLE, since those are no longer outstanding.
            // These are the lines with "Account Type" "Bank Account", that are applied to some "Document No." (Lines of type Bank Account without Doc. No are bank to bank transfers, which are not outstanding)
            BankAccReconciliation.SetFiltersOnBankAccReconLineTable(BankAccReconciliation, BankAccReconciliationLine);
            BankAccReconciliationLine.SetRange("Account Type", BankAccReconciliationLine."Account Type"::"Bank Account");
            if BankAccReconciliationLine.FindSet() then
                repeat
                    DocNo := BankAccReconciliationLine.GetAppliedToDocumentNo('|');
                    if DocNo <> '' then begin
                        // We will just subtract the "Applied Amount" 
                        GeneralBankAccountLedgerEntryFilters.SetFilter("Document No.", DocNo);
                        if not GeneralBankAccountLedgerEntryFilters.IsEmpty() then
                            Total -= BankAccReconciliationLine."Applied Amount";
                    end;
                until BankAccReconciliationLine.Next() = 0;
        end;
        exit(Total);
    end;

    local procedure ClearOpenFilters(var BankAccountLedgerEntry: Record "Bank Account Ledger Entry")
    begin
        BankAccountLedgerEntry.SetRange(Open);
        BankAccountLedgerEntry.SetRange("Closed at Date");
    end;

    local procedure SetGLAccountBalanceFilters(BankAccountPostingGroup: Record "Bank Account Posting Group"; StatementDate: Date; var GLEntry: Record "G/L Entry")
    begin
        GLEntry.SetRange("G/L Account No.", BankAccountPostingGroup."G/L Account No.");
        if (StatementDate <> 0D) then
            GLEntry.SetFilter("Posting Date", '<= %1', StatementDate);
    end;

    procedure GetGLAccountBalanceLCYForBankStatement(BankAccountStatement: Record "Bank Account Statement"): Decimal
    var
        BankAccount: Record "Bank Account";
        BankAccountPostingGroup: Record "Bank Account Posting Group";
        GLEntry: Record "G/L Entry";
    begin
        if not BankAccount.Get(BankAccountStatement."Bank Account No.") then
            exit(0);
        if not BankAccountPostingGroup.Get(BankAccount."Bank Acc. Posting Group") then
            exit(0);
        SetGLAccountBalanceFilters(BankAccountPostingGroup, BankAccountStatement."Statement Date", GLEntry);
        GLEntry.SetFilter(SystemCreatedAt, '< %1', BankAccountStatement.SystemCreatedAt);
        GLEntry.CalcSums(Amount);
        exit(GLEntry.Amount);
    end;

    procedure GetGLAccountBalanceLCY(BankAcc: Record "Bank Account"; BankAccPostingGroup: Record "Bank Account Posting Group"; StatementDate: Date): Decimal
    var
        GLAccount: Record "G/L Account";
        GLEntries: Record "G/L Entry";
    begin
        if BankAccPostingGroup."G/L Account No." = '' then
            exit(0);

        if not GLAccount.Get(BankAccPostingGroup."G/L Account No.") then
            exit(0);

        SetGLAccountBalanceFilters(BankAccPostingGroup, StatementDate, GLEntries);

        GLEntries.CalcSums(Amount);
        exit(GLEntries.Amount);
    end;

    procedure GetGLAccountBalance(TotalBalOnGLAccountLCY: Decimal; StatementDate: Date; CurrencyCode: Code[10]): Decimal
    var
        Currency: Record "Currency Exchange Rate";
        ExchangeRate: Decimal;
    begin
        ExchangeRate := Currency.ExchangeRate(StatementDate, CurrencyCode);
        exit(TotalBalOnGLAccountLCY * ExchangeRate);
    end;
}