Pages

Saturday, November 30, 2013

AX Retail 2012 R2: POS Error when cancel customer order

Noted:  This customization is based on AX 2012 R2 + CU6 

I found a discussion on LinkedIn mentioning about "Cancel customer order error".  So, I try to research its solution and find it.  


Scenario: 

When cancel customer order, an error occurred about "Voucher do not balance

POSApp.CustomerOrderRecovery(): The order could not be saved at this time.
LSRetailPosis.TransactionServiceException: TS InvokeMethod threw FaultException<TrackedFault>: CancelCustomerOrder(). TrackedFault:                   Voucher XXXXXX, date MM/DD/YYYY, account 13XXXX--, transaction currency amount X.XX, accounting currency amount X.XX, reporting currency amount 0.00, currency USD, text Payment of cancellation charge for the order 012526


Voucher XXXXXX, date MM/DD/YYYY, account 40XXXX-------, transaction currency amount -X.XX, accounting currency amount -X.XX, reporting currency amount 0.00, currency USD, text Payment of cancellation charge for the order 012526
Voucher XXXXXX, date MM/DD/YYYY, account 13XXXX--, transaction currency amount X.XX, accounting currency amount X.XX, reporting currency amount 0.00, currency USD, text Refund for the order 012526
The transactions on voucher 80000011 do not balance as per 11/30/2013. (accounting currency: 2.61 - reporting currency: 0.00)

Posting has been canceled.


Order cancellation charge

Previous blog, I mentioned about "Default deposit percentage".  Here, "Cancellation charge percentage" and "Cancellation charge code" are also configured in "Retail parameters > Customer orders".  And Credit account is also required.   
   



I tried to create an customer order with paid deposit (Paid $5.00 bill, Change $0.22).  So, when cancel the order, cancellation charge will be $2.39 by default.




When we look into Voucher transactions (General ledger > Inquiries) by adding Payment reference as the sales order number, we will see transactions of the deposit (cash & change).  




So, the expectation of this blog is when cancel customer order, the order should be settled with the cancellation charge.  

Customize Real-time Service Class

Because this is about Real-time service, then we need to fix in Retail HQ.  Normally, the method which handles the cancellation is cancelCustomerOrder() in \Classes\RetailTransactionService.  But once I customize here, the changes doesn't affect to POS.  So, I have to create methods in \Classes\RetailTransactionServiceEx (Extension Real-time service class) instead.     

First, we need to prepare a class to be similar to the standard one; RetailTransactionService: -

  • classDeclaration - copy all variables from standard class
  • settleCancellation - copy it, let's rename it for making different look from standard.  I named it as pkaSettleCancellation  

Then, you can create a new method as below: -

/// <summary>
/// Cancels the sales order.
/// </summary>
/// <param name="custOrderXmlStr">
/// XML string having the header, line details and charges, payments associated with the sales order to be canceled.
/// </param>
/// <returns>
/// A container having the status of order cancellation.
/// </returns>
public static container pkaCancelCustomerOrder(str custOrderXmlStr)
{
    int         i;
    str         error;
    boolean     success = false;
    SalesTable  salesTable;
    SalesLine   salesLine;
    MarkupTrans markupTrans;
    SalesId     salesId;
    MarkupCode  chargeCode;
    Voucher     voucher;
    Amount      amount,chargeCodeAmount, totalChargeCodeAmount;
    TaxGroup   taxGroup;
    TaxItemGroup taxItemGroup;
    TaxAmountCur    exclusiveTaxAmountCur = 0;
    TaxUncommitted  taxUncommitted;
    XmlDocument custOrderXml;
    XmlElement  xmlRoot;
    XmlElement  xmlRecord;
    XmlNodeList xmlRecordList;
    Counter     infologline    = infolog.num();
    LedgerJournalCheckPost  ledgerJournalCheckPost;
    LedgerJournalName  ledgerJournalName;
    LedgerJournalTable ledgerJournalTable;
    LedgerJournalTrans ledgerJournalTrans;
    AmountCur   refundAmount;
    MarkupTable markupTable;
    int fromLine;
    // ---> Phannasri, 2013.11.30
     Amount      pkaAmountChange;
    // <--- Phannasri, 2013.11.30

    // <GIN>
    RetailStoreId               storeId;
    TaxTable                    taxTable;
    boolean                     isIndia = SysCountryRegionCode::isLegalEntityInCountryRegion([#isoIN]);
    // </GIN>

    CurrencyCode currencyCode = CompanyInfo::standardCurrency();
    // <GEERU>
    boolean countryRegion_W = SysCountryRegionCode::isLegalEntityInCountryRegion(#easternEuropeAllandRU);
    // </GEERU>

    custOrderXml    = new XmlDocument();
    custOrderXml.loadXml(custOrderXmlStr);

    xmlRoot = custOrderXml.documentElement().getNamedElement('Id');
    salesId = xmlRoot.text();

    xmlRoot = custOrderXml.documentElement().getNamedElement('CurrencyCode');
    if(xmlRoot.text())
    {
        currencyCode = xmlRoot.text();
    }

    ttsbegin;
    salesTable = SalesTable::find(salesId);
    try
    {
        fromLine = Global::infologLine();

        // Cancelling the order
        if(salesTable.DocumentStatus == DocumentStatus::None)
        {
            while select forupdate salesLine
                where salesLine.SalesId == salesId
            {
                salesLine.RemainSalesPhysical = 0.0;
                salesLine.RemainInventPhysical = 0.0;
                salesLine.update();
            }
        }
        else
        {
            throw error('Order cannot be cancelled at this time from POS');
        }

        //creation of charge codes
        xmlRoot = custOrderXml.documentElement().getNamedElement('Charges');
        xmlRecordList = xmlRoot.childNodes();
        if(xmlRecordList)
        {
            for(i = 0; i < xmlRecordList.length(); i++)
            {
                xmlRecord = xmlRecordList.item(i);
                chargeCode = xmlRecord.getAttribute('Code');
                chargeCodeAmount = str2num(xmlRecord.getAttribute('Amount'));
                totalChargeCodeAmount += chargeCodeAmount;

                taxGroup = xmlRecord.getAttribute('TaxGroup');
                taxItemGroup = xmlRecord.getAttribute('TaxItemGroup');

                markupTrans.clear();
                markupTrans.MarkupCode = chargeCode;        //Set markup code, then call initFromSalesTable, then initValue to ensure tax groups are initialized correctly
                markupTrans.initFromSalesTable(salesTable);
                markupTrans.initValue();
                markupTrans.Value = chargeCodeAmount;
                markupTrans.CurrencyCode = currencyCode;
                markupTrans.TaxGroup = taxGroup;
                markupTrans.TaxItemGroup = taxItemGroup;
                markupTrans.insert();
            }
        }

        while select Prepayment, AmountCurCredit, PaymReference from ledgerJournalTrans
                where ledgerJournalTrans.PaymReference == salesId
                    && ledgerJournalTrans.Prepayment == NoYes::Yes
        {
            // ---> Phannasri, 2013.11.30
            pkaAmountChange += ledgerJournalTrans.AmountCurDebit;
            // <--- Phannasri, 2013.11.30
            amount += ledgerJournalTrans.AmountCurCredit;
        }

        // ---> Phannasri, 2013.11.30
        // In case, there is change amount for prepayment, consider it
        //refundAmount = amount - totalChargeCodeAmount;
        if (pkaAmountChange)
        {
            refundAmount = amount - pkaAmountChange - totalChargeCodeAmount;
        }
        else
        {
            refundAmount = amount - totalChargeCodeAmount;
        }
        // ---> Phannasri, 2013.11.30

        //Creation of payment journal
        if(amount)
        {
            select firstonly JournalType, JournalName, OffsetLedgerDimension, OffsetAccountType from ledgerJournalName where ledgerJournalName.JournalType == LedgerJournalType::CustPayment;

            ledgerJournalTable.clear();
            ledgerJournalTable.JournalName = ledgerJournalName.JournalName;
            ledgerJournalTable.initFromLedgerJournalName(ledgerJournalName.JournalName);
            ledgerJournalTable.Name = strFmt("@RET4505",salesTable.SalesId);
            ledgerJournalTable.OffsetLedgerDimension = ledgerJournalName.OffsetLedgerDimension;
            ledgerJournalTable.OffsetAccountType = ledgerJournalName.OffsetAccountType;
            ledgerJournalTable.CurrencyCode = currencyCode;
            ledgerJournalTable.insert();

            // Reset InclTax to match the SalesOrder because .Insert() forces it to match the value from LedgerJournalName
            ledgerJournalTable.LedgerJournalInclTax = salesTable.InclTax;
            ledgerJournalTable.update();

            // Use a common Voucher number for all payment entries
            voucher = NumberSeq::newGetNum(CustParameters::numRefCustPaymVoucher()).num();

            for(i = 0; i < 2; i++)
            {
                ledgerJournalTrans.clear();
                ledgerJournalTrans.initValue();
                ledgerJournalTrans.JournalNum           = ledgerJournalTable.JournalNum;
                ledgerJournalTrans.LineNum              = LedgerJournalTrans::lastLineNum(ledgerJournalTrans.JournalNum) + 1;
                ledgerJournalTrans.AccountType          = LedgerJournalACType::Cust;
                ledgerJournalTrans.parmAccount(salesTable.CustAccount);
                ledgerJournalTrans.DefaultDimension     = ledgerJournalTable.DefaultDimension;
                ledgerJournalTrans.initFromCustTable(CustTable::find(salesTable.CustAccount));
                ledgerJournalTrans.CurrencyCode         = currencyCode;
                ledgerJournalTrans.ExchRate             = Currency::exchRate(ledgerJournalTrans.CurrencyCode);
                ledgerJournalTrans.TransDate            = systemDateGet();
                ledgerJournalTrans.PaymReference        = salesTable.SalesId;
                ledgerJournalTrans.Prepayment           = NoYes::No;
                ledgerJournalTrans.Voucher              = voucher;
                ledgerJournalTrans.TransactionType      = LedgerTransType::Sales;

                if(i == 0 && totalChargeCodeAmount)
                {
                    //Insert the charge first, the uncommitted taxes are calculated on .Insert()
                    markupTable = MarkupTable::find(MarkupModuleType::Cust, chargeCode);
                    ledgerJournalTrans.OffsetAccountType        = LedgerJournalACType::Ledger;
                    ledgerJournalTrans.OffsetLedgerDimension    = DimensionDefaultingService::serviceCreateLedgerDimension(markupTable.VendorLedgerDimension);
                    ledgerJournalTrans.AmountCurDebit           = Currency::amount(totalChargeCodeAmount, currencyCode);
                    ledgerJournalTrans.TaxGroup                 = taxGroup;
                    ledgerJournalTrans.TaxItemGroup             = taxItemGroup;
                    // Payment of cancellation charge for the order %1
                    ledgerJournalTrans.Txt                      = strFmt("@RET260962", salesTable.SalesId);
                    ledgerJournalTrans.insert();


                    //After .Insert, read back the uncommitted exclusive taxes, as these need to be excluded from the refundAmount
                    if(!ledgerJournalTable.LedgerJournalInclTax)
                    {
                        select sum(SourceRegulateAmountCur) from taxUncommitted
                            where taxUncommitted.SourceTableId == ledgerJournalTrans.TableId
                                && taxUncommitted.SourceRecId == ledgerJournalTrans.Recid
                            ;
                        exclusiveTaxAmountCur = TaxUncommitted.SourceRegulateAmountCur;
                    }

                }
                // ---> Phannasri, 2013.11.30
                //else if(i == 1 && refundAmount)
                else if(i == 1 && refundAmount && exclusiveTaxAmountCur)
                // <--- Phannasri, 2013.11.30
                {
                    // Refund for the order %1
                    ledgerJournalTrans.Txt              = strFmt("@RET260964", salesTable.SalesId);
                    // ---> Phannasri, 2013.11.30 
                    markupTable = MarkupTable::find(MarkupModuleType::Cust, chargeCode);
                    ledgerJournalTrans.OffsetAccountType        = LedgerJournalACType::Ledger;
                    ledgerJournalTrans.OffsetLedgerDimension    = DimensionDefaultingService::serviceCreateLedgerDimension(markupTable.VendorLedgerDimension);
                    // <--- Phannasri, 2013.11.30 
                    ledgerJournalTrans.AmountCurDebit   = Currency::amount(refundAmount - abs(exclusiveTaxAmountCur), currencyCode);
                    ledgerJournalTrans.insert();
                }
            }
            ledgerJournalCheckPost = LedgerJournalCheckPost::newRBOLedgerJournalTable(ledgerJournalTable,NoYes::Yes,NoYes::No);
            ledgerJournalCheckPost.run();

            RetailTransactionServiceEx::pkaSettleCancellation(salesTable, voucher);
        }
        error = "";
        success = true;
        ttscommit;
    }
    catch
    {
        ttsabort;
        error = RetailTransactionService::getInfologMessages(fromLine);
        RetailTracer::Error('RetailTransactionServiceEx', funcName(), error);
        success = false;
    }
    return [success, error];
} 

Originally, I copy the method from standard one and customize some parts (noticing from red texts).  Something like, there is a bug about "Uncommitted exclusive taxes".  So, I check if no taxes, skip posting its transaction.  


Retail POS Customization

In POS, we customize class SalesOrder.cs in "Retail SDK\POS Plug-ins\Services\SalesOrder". Search for the method "CancelCustomerOrder", change code to call the new extension method using InvokeExtension instead. 

                // ---> Phannasri, 2013.11.30
                //containerArray = Application.TransactionServices.Invoke("CancelCustomerOrder", xmlString);
                containerArray = Application.TransactionServices.InvokeExtension("pkaCancelCustomerOrder", xmlString);

                // ---> Phannasri, 2013.11.30

Then, compile SalesOrder.dll and replace it in Retail POS services directory (C:\Program Files (x86)\Microsoft Dynamics AX\60\Retail POS\Services). 

This time, you can successfully cancel the order.  Once look into the voucher transaction, you will see that the order is settled with cancellation charge.   



However, this is just a temporary solution.  Let's hope that Microsoft will give us a hotfix for this issue soon.  ;-)  


Thursday, November 28, 2013

AX Retail 2012 R2: Customization of End-of-day Reports (X & Z Reports)

This blog will show you how to customize a Retail POS report focusing on X / Z report.  


Scenario

Standard X / Z report don't show the total paid of customer order deposits.  Outlet manager doesn't know why there's more money in drawer.  Customize those reports by inserting a line called "Sales order deposit".    





What is X / Z Report

They are in the group of drawer (and shift) operations. 


  • X Report shows the register’s activity since the register opened.  It shows information such as the sales, the sum of all discounts given, and the customer count for the customers who have visited the store during the current shift (see above picture).  Printing by "Print X" operation.  
  • Z Report shows information about the last closed shift.  Physically, it is similar to the X Report but consists of "Tenders" section to conclude all tenders of that shift.  POS automatically prints a Z report when a user closes a shift.  Reprinting by "Reprint Z" operation. 

Customer order payments   

Let me briefly explain about the sales order deposit.  The "Default deposit percentage" is in Retail parameters.  Once you create a new customer order and pay, the default deposit will be calculated from sales price including tax.  





Also when the customer picks up order, we will recall order and click "Pick up order".  That payment amount won't be shown on X/Z report too.    



End-of-Day customization

Before customize Retail POS, we need to prepare language texts by opening AX form "RetailLanguageText" from AOT.  All new text shown on POS have to prepare here.  And then, run N-Job "Registers" to push data to store DB.  Because some users in Singapore want to see POS screen as Chinese texts, I usually create 3 languages; en-us, en-sg, zh-hans (my Chinese colleague will help to edit text later on). 

Noted: normally this form isn't shown in any menus but I often use it.  So, I link it under Retail > Setup > POS   

All End-of-Day things are in "Retail SDK\POS Plug-ins\Services\EOD".  There are 3 modified classes in this blog: -
  • BatchCalculation.cs manages batch calculation 
  • BatchPrinting.cs manage printing 
  • EOD.cs manage End-of-Day operations     

Batch Calculation

There are 3 main tables to be noticed: -
  • Table PosSeedValues keeps next running number, TypeID for batch = 7 
  • Table RetailTransactionTable keeps transaction header.  BatchID and payment amount are marked here.  When we open new shift, all retail transactions will be marked with the new Batch ID.   
  • Table RetailPosBatchTable keeps total amount / count of fields after close shift.  
In class BatchCalculation.cs. First, declare constant variable to get payment amount summary (including tax) of customer order type from table RetailTransactionTable. And create a function to get data.    

        private const string sqlPKADepositTotal = "SELECT SUM(PAYMENTAMOUNT) " +
            "FROM RETAILTRANSACTIONTABLE AS H " +
            sqlWhereBatchClause +
            "AND [TYPE] = 19  AND ENTRYSTATUS <> 1 ";

        public static decimal PKACalculateDepositTotal(this Batch batch)
        {
            decimal ret = 0;
            DbConnection connection = ApplicationSettings.Database.LocalConnection;

            try
            {
                if (connection.State != ConnectionState.Open)
                    connection.Open();

                using (DbCommand dBCommand = new SqlCommand())
                {
                    dBCommand.Connection = connection;

                    dBCommand.Initialize(sqlPKADepositTotal, batch);
                    dBCommand.AddParameter("@TRANSACTIONSTATUS", TransactionStatus.Normal);
                    dBCommand.AddParameter("@TAXINCLUSIVE", ApplicationSettings.Terminal.TaxIncludedInPrice);
                    ret = DBUtil.ToDecimal(dBCommand.ExecuteScalar());
                }
            }
            finally
            {
                if (connection.State == ConnectionState.Open)
                    connection.Close();
            }
            return ret;

        }

Batch Printing

In class BatchPrinting.cs.  Insert the deposit total line in Print function, I also insert sub total of (SalesTotal - Return + Deposit) 

       private static readonly string pkaDoubleLine = string.Empty.PadLeft(paperWidth, '=');

       public static void Print(this Batch batch, ReportType reportType)
        {
            // TextID's for the Z/X Report are reserved at 7000 - 7099

            StringBuilder reportLayout = new StringBuilder(2500);

            // Header
            reportLayout.PrepareHeader(batch, reportType);

            decimal pkaDepositTotal = BatchCalculation.PKACalculateDepositTotal(batch);

            // Total Amounts
            reportLayout.AppendReportLine(7015);
            reportLayout.AppendReportLine(7016, RoundDecimal(batch.SalesTotal));
            reportLayout.AppendReportLine(7017, RoundDecimal(-(batch.ReturnsTotal)));
            reportLayout.AppendReportLine(22284, RoundDecimal(pkaDepositTotal));  //Deposit
            reportLayout.AppendLine(singleLine);
            reportLayout.AppendReportLine(22278, RoundDecimal(batch.SalesTotal - batch.ReturnsTotal + pkaDepositTotal));  //Deposit
            reportLayout.AppendLine(pkaDoubleLine);
  
Then, the result in Total amounts section is as below: -



Save Deposit Total When Close Shift

Moreover, when we close shift, all total amount / count of fields will be created to RetailPosBatchTable.  We may save the deposit total to the table for later reference.  So, I add new field named PKASalesDepositTotal to the table.  Then, add constant variable for Update statement in BatchCalculation.cs and create a function to update field.           

        private const string sqlPKAWhereBatchUpdateClause = "WHERE STOREID = @STOREID " +
            "AND TERMINALID = @TERMINALID " +
            "AND DATAAREAID = @DATAAREAID " +
            "AND BATCHID = @BATCHID ";

        private const string sqlPKAUpdateBatch = "UPDATE RETAILPOSBATCHTABLE " +
            "SET PKASALESDEPOSITTOTAL = {0} " +

            sqlMSCWhereBatchUpdateClause;

        public static void PKACalculate(this Batch batch)
        {
            DbConnection connection = ApplicationSettings.Database.LocalConnection;

            try
            {
                if (connection.State != ConnectionState.Open)
                    connection.Open();

                using (DbCommand dBCommand = new SqlCommand())
                {
                    dBCommand.Connection = connection;
                    PKACalculateHeaderAdditional(dBCommand, batch);
                }
            }
            finally
            {
                if (connection.State == ConnectionState.Open)
                    connection.Close();
            }
        }

        private static void PKACalculateHeaderAdditional(DbCommand dBCommand, Batch batch)
        {
            decimal batchValue = PKACalculateDepositTotal(batch); 

            string commandText = string.Format(sqlPKAUpdateBatch, batchValue);
            dBCommand.CommandText = commandText;
            dBCommand.Parameters.Clear();
            dBCommand.Parameters.Add(new SqlParameter("@STOREID", batch.StoreId));
            dBCommand.Parameters.Add(new SqlParameter("@TERMINALID", batch.TerminalId));
            dBCommand.Parameters.Add(new SqlParameter("@BATCHID", batch.BatchId));
            dBCommand.Parameters.Add(new SqlParameter("@DATAAREAID", ApplicationSettings.Database.DATAAREAID));
            dBCommand.ExecuteNonQuery();

        }

Last step, modify class EOD.cs --> method CloseShift.  Before printing Z report, insert codes to calculate and update the new total field.
                // Calculate additional fields
               
batch.PKACalculate();

                // Print Z report if user has permissions.
                IUserAccessSystem userAccessSystem = Application.BusinessLogic.UserAccessSystem;
                if (userAccessSystem.UserHasAccess(ApplicationSettings.Terminal.TerminalOperator.OperatorId, PosisOperations.PrintZ))
                {
                    POSFormsManager.ShowPOSMessageWithBackgroundWorker(99, delegate { batch.Print(ReportType.ZReport); });
                }


Finally, compile EOD.dll and replace it in Retail POS services directory (C:\Program Files (x86)\Microsoft Dynamics AX\60\Retail POS\Services).  

Monday, November 18, 2013

AX Retail 2012 R2: Customization of Real-time Service

This blog, I will show you some examples how to customize things relating to Real-time Service.  


Scenario  

During -1/+1 hour of store closing time. Before "Tender declaration" or "Close shift", check whether or not there is opened customer order which is requested to pick up at that store on current date. 







This relates to Real-time Service because POS needs to check opened customer order from Retail HQ. 

Store shipping warehouse and closing hour

Shipping warehouse and closing hour are configured in Retail stores detail form; Retail > Common > Retail channels > Retail stores   




Retail HQ: Method for checking opened customer order

We needs to create a method to return a set of orders with status is opened, delivery date is current date and delivery warehouse is same as the store. 

Add the following method in the RetailTransactionServiceEX class.  The method will return a set of sales orders in container array. 


public static container pkaSearchSalesOrderOpenOrderList(str _storeId)
{
    SalesTable                  salesTable;

    container                   salesOrder = [true,''];

    RetailStoreId               retailStoreId = _storeId;
    RetailStoreTable            retailStoreTable = RetailStoreTable::find(retailStoreId);
    SalesShippingDateRequested  requestedDeliveryDate = systemDateGet();

    if(retailStoreTable.RecId)
    {
        while select SalesId
        from salesTable
        order by salesTable.CreatedDateTime desc
        where (
                //Result must not contain SalesStatus::Canceled && SalesStatus::Delivered && SalesStatus::Invoiced
                (salesTable.SalesStatus == SalesStatus::None ||
                 salesTable.SalesStatus == SalesStatus::Backorder)
                //Check delivery date as current date
                && (salesTable.DeliveryDate == requestedDeliveryDate)
                //Check warehouse same as retail store
                && (salesTable.InventLocationId == retailStoreTable.inventLocation)
              )
        {
            salesOrder = conins(salesOrder, conlen(salesOrder) + 1, RetailTransactionService::getSalesOrder(salesTable.SalesId));
        }
    }

    return salesOrder;
}

Retail POS: Calling extension methods 

Create a Blank Operation button with: -

  • operation number: CHECKSALESORDER 
  • param: TENDERDECLARATION or CLOSESHIFT --> this will be process after checking order


I will simply create functions in BlankOperations.cs of Retail SDK for processing above operation number and its param. 

        /// <summary>
        /// Displays an alert message according operation id passed.
        /// </summary>
        /// <param name="operationInfo"></param>
        /// <param name="posTransaction"></param>        
        [System.Diagnostics.CodeAnalysis.SuppressMessage("Microsoft.Design", "CA1062:Validate arguments of public methods", MessageId = "0", Justification = "Grandfather")]
        public void BlankOperation(IBlankOperationInfo operationInfo, IPosTransaction posTransaction)
        {

            switch ((operationInfo.OperationId).ToUpperInvariant().Replace(" ", string.Empty))
            {
                #region Check Sales Order
                case "CHECKSALESORDER": //Check Sales Order
                    if (this.CheckNullOperationParam(operationInfo))
                    {
                        this.CheckSalesOrder(operationInfo); 
                    }
                    break;
                #endregion
                default:
                    //default, just echo the operation number and parameter value 
                    break;
            }
        }

For this Blank Operation button, parameter is mandatory.  Make sure that param is defined.  

        private bool CheckNullOperationParam(IBlankOperationInfo operationInfo)
        {
            StringBuilder comment = new StringBuilder(128);
            bool ret = true; 

            if (operationInfo.Parameter == "")
            {
                //throw error if operation parameter is empty 
                comment.Append("Operation parameter not found.");
                SerializationHelper.ShowMessage(comment.ToString()); 
                // Set this property to true when your operation is handled
                operationInfo.OperationHandled = true;
                ret = false;
            }
            return ret; 
        }

As the scenario "During -1/+1 hour of store closing time", we have to compare current time with closing hour configuration from Store table.  

Getting StoreId from LSRetailPosis.Settings.ApplicationSettings.Terminal.StoreId and get closing time from RetailStoreTable in Store DB. 

If no opened order, user is able to "Tender declaration" or "Close shift" by calling Application.RunOperation command.   

        private void CheckSalesOrder(IBlankOperationInfo operationInfo)
        {
            string storeId = LSRetailPosis.Settings.ApplicationSettings.Terminal.StoreId;
            bool isStoreClosingHour = DataCollection.IsStoreClosingHour(Application, storeId);
            bool chkSalesOrder = false;

            if (isStoreClosingHour)
            {
                chkSalesOrder = this.CheckSalesOrderExists(operationInfo); 
            }
            else
            {
                chkSalesOrder = true; 
            }

            if (chkSalesOrder)
            {
                switch ((operationInfo.Parameter).ToUpperInvariant().Replace(" ", string.Empty))
                {
                    case "TENDERDECLARATION":  //Tender Declaration
                        Application.RunOperation(PosisOperations.TenderDeclaration, null);
                        break;
                    case "CLOSESHIFT":  //Tender Declaration
                        Application.RunOperation(PosisOperations.CloseShift, null);
                        break;
                    default:
                        StringBuilder comment = new StringBuilder(128);
                        comment.AppendFormat("Specified action for operation {0} not found", operationInfo.Parameter);
                        SerializationHelper.ShowMessage(comment.ToString());
                        break;
                }
            }
        }

        /// <summary>
        /// Check whether or not system will check opened Sales Order at the moment
        /// </summary>
        /// <param name="Application"></param>
        /// <param name="StoreId"></param>
        /// <returns>Return True status for checking opened Sales Order at the moment</returns>
        internal static bool IsStoreClosingHour(IApplication Application, string StoreId)
        {
            bool ret = false; 
            DataTable store = new DataTable();
            string queryString = "SELECT DATEDIFF(HOUR, CONVERT(TIME, SYSDATETIME()), CONVERT(TIME, DATEADD(SECOND, [OPENTO], 0), 114)) AS TIMEDIFF " + 
                                    "FROM RETAILSTORETABLE AS STORE " +
                                    "WHERE STORE.STORENUMBER = '" + StoreId + "' ";
            store = DataCollection.GetData(Application, queryString);
            if (store.Rows.Count > 0)
            {
                DataRow row = store.Rows[0];
                // if current time is different form 1 / -1 hour of "Opening To" hour of store
                // Return True status for checking opened Sales Order at the moment

                if ((int)row["TIMEDIFF"] >= -1 && (int)row["TIMEDIFF"] <= 1)
                {
                    ret = true; 
                }
            }


            return ret;
        }

Consuming the Real-time Service extension methods from a POS, we need to call InvokeExtensionMethod with the method name and parameter StoreId. 

        private bool CheckSalesOrderExists(IBlankOperationInfo operationInfo)
        {
            StringBuilder comment = new StringBuilder(128);
            bool ret = true;

            // Begin by checking if there is a connection to the Transaction Service
            this.Application.TransactionServices.CheckConnection();

            string storeId = LSRetailPosis.Settings.ApplicationSettings.Terminal.StoreId;

            ReadOnlyCollection<object> containerArray;
            DataTable salesOrders = new DataTable(); 
            bool retValue;
            string retComment;

            containerArray = this.Application.TransactionServices.InvokeExtension("pkaSearchSalesOrderOpenOrderList", storeId);
            retValue = SerializationHelper.ConvertToBooleanAtIndex(containerArray, 1);
            retComment = containerArray[2].ToString();

            salesOrders.Columns.Add("SALESID", typeof(string));

            for (int i = 3; i < containerArray.Count; i++)
            {

                IList salesRecord = (IList)containerArray[i];

                bool recordRetVal = SerializationHelper.ConvertToBooleanAtIndex(salesRecord, 0);
                string recordComment = SerializationHelper.ConvertToStringAtIndex(salesRecord, 1);

                // The particular sales order at this position in the container is blank so we need
                // to jump over it process the next one...
                if (!recordRetVal)
                    continue;

                DataRow row = salesOrders.NewRow();

                // some of these fields may not be properly initialized even if recordRetVal is true
                row["SALESID"] = SerializationHelper.ConvertToStringAtIndex(salesRecord, 2);

                salesOrders.Rows.Add(row);
            }

            if (salesOrders.Rows.Count > 0)
            {
                if (salesOrders.Rows.Count == 1)
                {
                    DataRow row = salesOrders.Rows[0];
                    comment.AppendFormat("Outstanding sales order {0} is found.", row["SALESID"]);
                }
                else
                {
                    comment.AppendFormat("There are outstanding sales orders found.");
                }
                SerializationHelper.ShowMessage(comment.ToString()); 
                
                // Set this property to true when your operation is handled
                operationInfo.OperationHandled = true;
                ret = false;
            }

            return ret; 
        }

Tip:  I also create another utility class for converting values and showing message by calling frmMessage dialog form.

        internal static bool ConvertToBooleanAtIndex(IList list, int index)
        {
            try
            {
                return Convert.ToBoolean(list[index]);
            }
            catch
            {
                return false;
            }
        }

        internal static string ConvertToStringAtIndex(IList list, int index)
        {
            try
            {
                return Convert.ToString(list[index]);
            }
            catch
            {
                return string.Empty;
            }
        }

        internal static void ShowMessage(string message, MessageBoxIcon icon = MessageBoxIcon.Error)
        {
            using (LSRetailPosis.POSProcesses.frmMessage dialog = new LSRetailPosis.POSProcesses.frmMessage(message, MessageBoxButtons.OK, icon))
            {
                LSRetailPosis.POSProcesses.POSFormsManager.ShowPOSForm(dialog);
            }
        }