diff --git a/src/Apps/W1/Shopify/App/src/Document Links/Codeunits/ShpfyDocumentLinkMgt.Codeunit.al b/src/Apps/W1/Shopify/App/src/Document Links/Codeunits/ShpfyDocumentLinkMgt.Codeunit.al index 2c2830ca090..b95417a2796 100644 --- a/src/Apps/W1/Shopify/App/src/Document Links/Codeunits/ShpfyDocumentLinkMgt.Codeunit.al +++ b/src/Apps/W1/Shopify/App/src/Document Links/Codeunits/ShpfyDocumentLinkMgt.Codeunit.al @@ -5,9 +5,11 @@ namespace Microsoft.Integration.Shopify; +using Microsoft.Finance.GeneralLedger.Journal; using Microsoft.Sales.Document; using Microsoft.Sales.History; using Microsoft.Sales.Posting; +using Microsoft.Warehouse.Activity; codeunit 30262 "Shpfy Document Link Mgt." { @@ -63,11 +65,9 @@ codeunit 30262 "Shpfy Document Link Mgt." end; [EventSubscriber(ObjectType::Codeunit, Codeunit::"Sales-Post", 'OnAfterPostSalesDoc', '', true, false)] - local procedure OnAfterSalesPosting(var SalesHeader: Record "Sales Header"; PreviewMode: Boolean; SalesShptHdrNo: Code[20]; SalesInvHdrNo: Code[20]; RetRcpHdrNo: Code[20]; SalesCrMemoHdrNo: Code[20]) + local procedure OnAfterSalesPosting(var SalesHeader: Record "Sales Header"; PreviewMode: Boolean; CommitIsSuppressed: Boolean; InvtPickPutaway: Boolean; SalesShptHdrNo: Code[20]; SalesInvHdrNo: Code[20]; RetRcpHdrNo: Code[20]; SalesCrMemoHdrNo: Code[20]) var - SalesShipmentHeader: Record "Sales Shipment Header"; - SalesInvoiceLine: Record "Sales Invoice Line"; - SalesShipments: List of [Code[20]]; + ShpfyAutoPostTransactions: Codeunit "Shpfy Auto Post Transactions"; begin if SalesHeader.IsTemporary() then exit; @@ -75,6 +75,54 @@ codeunit 30262 "Shpfy Document Link Mgt." if PreviewMode then exit; + CreateDocLinksToBCDocs(SalesHeader, SalesShptHdrNo, SalesInvHdrNo, RetRcpHdrNo, SalesCrMemoHdrNo); + + if CommitIsSuppressed or InvtPickPutaway then + exit; + + // CreateDocLinksToBCDocs can open a write transaction after Sales-Post's final commit. + // Flush those links before invoking the isolated Codeunit.Run posting operations. + Commit(); + ShpfyAutoPostTransactions.AutoPostTransactions(SalesInvHdrNo, SalesCrMemoHdrNo, HasJournalPermissions()); + end; + + [EventSubscriber(ObjectType::Codeunit, Codeunit::"Whse.-Activity-Post", 'OnAfterPostWhseActivityCompleted', '', false, false)] + local procedure OnAfterPostWhseActivityCompleted(WhseActivHeader: Record "Warehouse Activity Header"; var SalesHeader: Record "Sales Header"; SuppressCommit: Boolean; IsPreview: Boolean) + var + ShpfyAutoPostTransactions: Codeunit "Shpfy Auto Post Transactions"; + begin + if not (WhseActivHeader.Type in [WhseActivHeader.Type::"Invt. Pick", WhseActivHeader.Type::"Invt. Put-away"]) then + exit; + if SuppressCommit or IsPreview or (SalesHeader."Last Posting No." = '') then + exit; + + Commit(); + case SalesHeader."Document Type" of + SalesHeader."Document Type"::Order, + SalesHeader."Document Type"::Invoice: + ShpfyAutoPostTransactions.AutoPostTransactions(SalesHeader."Last Posting No.", '', HasJournalPermissions()); + SalesHeader."Document Type"::"Return Order", + SalesHeader."Document Type"::"Credit Memo": + ShpfyAutoPostTransactions.AutoPostTransactions('', SalesHeader."Last Posting No.", HasJournalPermissions()); + end; + end; + + local procedure HasJournalPermissions(): Boolean + var + GenJournalBatch: Record "Gen. Journal Batch"; + GenJournalLine: Record "Gen. Journal Line"; + begin + exit( + GenJournalBatch.ReadPermission() and GenJournalBatch.WritePermission() and + GenJournalLine.ReadPermission() and GenJournalLine.WritePermission()); + end; + + local procedure CreateDocLinksToBCDocs(var SalesHeader: Record "Sales Header"; SalesShptHdrNo: Code[20]; SalesInvHdrNo: Code[20]; RetRcpHdrNo: Code[20]; SalesCrMemoHdrNo: Code[20]) + var + SalesShipmentHeader: Record "Sales Shipment Header"; + SalesInvoiceLine: Record "Sales Invoice Line"; + SalesShipments: List of [Code[20]]; + begin DocLinkToBCDoc.SetRange("Document Type", ShpfyBCDocumentTypeConvert.Convert(SalesHeader."Document Type")); DocLinkToBCDoc.SetRange("Document No.", SalesHeader."No."); DocLinkToBCDoc.SetCurrentKey("Document Type", "Document No."); diff --git a/src/Apps/W1/Shopify/App/src/PermissionSets/ShpfyObjects.PermissionSet.al b/src/Apps/W1/Shopify/App/src/PermissionSets/ShpfyObjects.PermissionSet.al index eb2de3d3f05..52dd3220b70 100644 --- a/src/Apps/W1/Shopify/App/src/PermissionSets/ShpfyObjects.PermissionSet.al +++ b/src/Apps/W1/Shopify/App/src/PermissionSets/ShpfyObjects.PermissionSet.al @@ -103,6 +103,10 @@ permissionset 30104 "Shpfy - Objects" report "Shpfy Translator" = X, codeunit "Company Details Checklist Item" = X, codeunit "Shpfy Authentication Mgt." = X, + codeunit "Shpfy Auto Gen. Jnl.-Post" = X, + codeunit "Shpfy Auto Post Eligibility" = X, + codeunit "Shpfy Auto Post Finalize" = X, + codeunit "Shpfy Auto Post Transactions" = X, codeunit "Shpfy Background Syncs" = X, codeunit "Shpfy Balance Today" = X, codeunit "Shpfy Base64" = X, @@ -301,6 +305,7 @@ permissionset 30104 "Shpfy - Objects" page "Shpfy Customers" = X, page "Shpfy Data Capture List" = X, page "Shpfy Disputes" = X, + page "Shpfy Filter Transactions" = X, page "Shpfy Fulfillment Order Card" = X, page "Shpfy Fulfillment Order Lines" = X, page "Shpfy Fulfillment Orders" = X, diff --git a/src/Apps/W1/Shopify/App/src/Transactions/Codeunits/ShpfyAutoGenJnlPost.Codeunit.al b/src/Apps/W1/Shopify/App/src/Transactions/Codeunits/ShpfyAutoGenJnlPost.Codeunit.al new file mode 100644 index 00000000000..f4941e43516 --- /dev/null +++ b/src/Apps/W1/Shopify/App/src/Transactions/Codeunits/ShpfyAutoGenJnlPost.Codeunit.al @@ -0,0 +1,89 @@ +// ------------------------------------------------------------------------------------------------ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. See License.txt in the project root for license information. +// ------------------------------------------------------------------------------------------------ + +namespace Microsoft.Integration.Shopify; + +using Microsoft.Finance.GeneralLedger.Journal; +using Microsoft.Finance.GeneralLedger.Posting; + +/// +/// Codeunit Shpfy Auto Gen. Jnl.-Post (ID 30422). +/// Creates a dedicated, single-use journal batch (cloned from the configured one) and builds the general +/// journal line(s) for a single Shopify order/refund payment transaction into it. It is invoked through +/// Codeunit.Run so that any failure while creating the batch or building lines is trapped and rolled back +/// without leaving a batch or line behind. While bound, it also pre-confirms the "posting after working +/// date" prompt so the automatic posting stays non-interactive. +/// +codeunit 30422 "Shpfy Auto Gen. Jnl.-Post" +{ + Access = Internal; + EventSubscriberInstance = Manual; + TableNo = "Shpfy Order Transaction"; + + trigger OnRun() + begin + CreateBatchAndBuildLines(Rec); + end; + + var + PaymentMethodMapping: Record "Shpfy Payment Method Mapping"; + PostingDate: Date; + IsolatedTemplateName: Code[10]; + IsolatedBatchName: Code[10]; + + internal procedure SetParameters(NewPaymentMethodMapping: Record "Shpfy Payment Method Mapping"; NewPostingDate: Date) + begin + PaymentMethodMapping := NewPaymentMethodMapping; + PostingDate := NewPostingDate; + Clear(IsolatedTemplateName); + Clear(IsolatedBatchName); + end; + + internal procedure GetIsolatedBatch(var NewTemplateName: Code[10]; var NewBatchName: Code[10]) + begin + NewTemplateName := IsolatedTemplateName; + NewBatchName := IsolatedBatchName; + end; + + local procedure CreateBatchAndBuildLines(var OrderTransaction: Record "Shpfy Order Transaction") + var + SuggestPayments: Report "Shpfy Suggest Payments"; + begin + CreateIsolatedBatch(); + SuggestPayments.SetJournalParameters(IsolatedTemplateName, IsolatedBatchName, PostingDate); + SuggestPayments.GetOrderTransactions(OrderTransaction); + SuggestPayments.CreateGeneralJournalLines(); + end; + + local procedure CreateIsolatedBatch() + var + ConfiguredBatch: Record "Gen. Journal Batch"; + IsolatedBatch: Record "Gen. Journal Batch"; + begin + ConfiguredBatch.Get(PaymentMethodMapping."Auto-Post Jnl. Template", PaymentMethodMapping."Auto-Post Jnl. Batch"); + IsolatedBatch := ConfiguredBatch; + IsolatedBatch.Name := GetUniqueBatchName(ConfiguredBatch."Journal Template Name"); + IsolatedBatch.Insert(true); + IsolatedTemplateName := IsolatedBatch."Journal Template Name"; + IsolatedBatchName := IsolatedBatch.Name; + end; + + local procedure GetUniqueBatchName(TemplateName: Code[10]): Code[10] + var + ExistingBatch: Record "Gen. Journal Batch"; + CandidateName: Code[10]; + begin + repeat + CandidateName := CopyStr('SHPFY' + CopyStr(DelChr(Format(CreateGuid()), '=', '{}-'), 1, 5), 1, 10); + until not ExistingBatch.Get(TemplateName, CandidateName); + exit(CandidateName); + end; + + [EventSubscriber(ObjectType::Codeunit, Codeunit::"Gen. Jnl.-Post Batch", 'OnBeforeCheckLine', '', false, false)] + local procedure PreconfirmWorkingDateOnBeforeCheckLine(var PostingAfterWorkingDateConfirmed: Boolean) + begin + PostingAfterWorkingDateConfirmed := true; + end; +} diff --git a/src/Apps/W1/Shopify/App/src/Transactions/Codeunits/ShpfyAutoPostEligibility.Codeunit.al b/src/Apps/W1/Shopify/App/src/Transactions/Codeunits/ShpfyAutoPostEligibility.Codeunit.al new file mode 100644 index 00000000000..5420bbc3cf4 --- /dev/null +++ b/src/Apps/W1/Shopify/App/src/Transactions/Codeunits/ShpfyAutoPostEligibility.Codeunit.al @@ -0,0 +1,85 @@ +// ------------------------------------------------------------------------------------------------ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. See License.txt in the project root for license information. +// ------------------------------------------------------------------------------------------------ + +namespace Microsoft.Integration.Shopify; + +using Microsoft.Sales.Document; +using Microsoft.Sales.History; + +/// +/// Codeunit Shpfy Auto Post Eligibility (ID 30424). +/// Keeps automatic-posting and transaction-list filtering predicates aligned. +/// +codeunit 30424 "Shpfy Auto Post Eligibility" +{ + Access = Internal; + + internal procedure IsReadyToPost(OrderTransaction: Record "Shpfy Order Transaction"; var PaymentMethodMapping: Record "Shpfy Payment Method Mapping"): Boolean + begin + exit( + GetPaymentMethodMapping(OrderTransaction, PaymentMethodMapping) and + IsMappingConfigured(PaymentMethodMapping) and + IsTransactionPostable(OrderTransaction)); + end; + + internal procedure GetPaymentMethodMapping(OrderTransaction: Record "Shpfy Order Transaction"; var PaymentMethodMapping: Record "Shpfy Payment Method Mapping"): Boolean + begin + exit(PaymentMethodMapping.Get(OrderTransaction.Shop, OrderTransaction.Gateway, OrderTransaction."Credit Card Company")); + end; + + internal procedure IsMappingConfigured(PaymentMethodMapping: Record "Shpfy Payment Method Mapping"): Boolean + begin + exit( + PaymentMethodMapping."Post Automatically" and + (PaymentMethodMapping."Auto-Post Jnl. Template" <> '') and + (PaymentMethodMapping."Auto-Post Jnl. Batch" <> '')); + end; + + internal procedure IsTransactionPostable(OrderTransaction: Record "Shpfy Order Transaction"): Boolean + begin + if OrderTransaction.Status <> OrderTransaction.Status::Success then + exit(false); + if not (OrderTransaction.Type in [OrderTransaction.Type::Capture, OrderTransaction.Type::Sale, OrderTransaction.Type::Refund]) then + exit(false); + + OrderTransaction.CalcFields(Used); + if OrderTransaction.Used then + exit(false); + if OpenSalesDocumentExists(OrderTransaction) then + exit(false); + exit(PostedDocumentExists(OrderTransaction)); + end; + + local procedure OpenSalesDocumentExists(OrderTransaction: Record "Shpfy Order Transaction"): Boolean + var + SalesHeader: Record "Sales Header"; + begin + if OrderTransaction.Type = OrderTransaction.Type::Refund then begin + if OrderTransaction."Refund Id" = 0 then + exit(true); + SalesHeader.SetRange("Shpfy Refund Id", OrderTransaction."Refund Id"); + exit(not SalesHeader.IsEmpty()); + end; + + if OrderTransaction."Shopify Order Id" = 0 then + exit(true); + SalesHeader.SetRange("Shpfy Order Id", OrderTransaction."Shopify Order Id"); + exit(not SalesHeader.IsEmpty()); + end; + + local procedure PostedDocumentExists(OrderTransaction: Record "Shpfy Order Transaction"): Boolean + var + SalesCrMemoHeader: Record "Sales Cr.Memo Header"; + SalesInvoiceHeader: Record "Sales Invoice Header"; + begin + if OrderTransaction.Type = OrderTransaction.Type::Refund then begin + SalesCrMemoHeader.SetRange("Shpfy Refund Id", OrderTransaction."Refund Id"); + exit(not SalesCrMemoHeader.IsEmpty()); + end; + + SalesInvoiceHeader.SetRange("Shpfy Order Id", OrderTransaction."Shopify Order Id"); + exit(not SalesInvoiceHeader.IsEmpty()); + end; +} diff --git a/src/Apps/W1/Shopify/App/src/Transactions/Codeunits/ShpfyAutoPostFinalize.Codeunit.al b/src/Apps/W1/Shopify/App/src/Transactions/Codeunits/ShpfyAutoPostFinalize.Codeunit.al new file mode 100644 index 00000000000..be3b83e0f13 --- /dev/null +++ b/src/Apps/W1/Shopify/App/src/Transactions/Codeunits/ShpfyAutoPostFinalize.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.Integration.Shopify; + +using Microsoft.Finance.GeneralLedger.Journal; + +/// +/// Codeunit Shpfy Auto Post Finalize (ID 30423). +/// Runs cleanup and skipped-record persistence in isolated, trappable transactions. +/// +codeunit 30423 "Shpfy Auto Post Finalize" +{ + Access = Internal; + TableNo = "Shpfy Order Transaction"; + Permissions = tabledata "Gen. Journal Batch" = rimd, + tabledata "Gen. Journal Line" = rimd; + + trigger OnRun() + begin + if CleanupBatch then + RemoveIsolatedBatch(); + if FailureReason <> '' then + LogFailure(Rec); + end; + + var + TemplateName: Code[10]; + BatchName: Code[10]; + FailureReason: Text; + CleanupBatch: Boolean; + + internal procedure SetCleanupParameters(NewTemplateName: Code[10]; NewBatchName: Code[10]) + begin + TemplateName := NewTemplateName; + BatchName := NewBatchName; + CleanupBatch := true; + Clear(FailureReason); + end; + + internal procedure SetFailureParameters(NewFailureReason: Text) + begin + Clear(TemplateName); + Clear(BatchName); + CleanupBatch := false; + FailureReason := NewFailureReason; + end; + + local procedure RemoveIsolatedBatch() + var + IsolatedBatch: Record "Gen. Journal Batch"; + begin + if IsolatedBatch.Get(TemplateName, BatchName) then + IsolatedBatch.Delete(true); + end; + + local procedure LogFailure(OrderTransaction: Record "Shpfy Order Transaction") + var + Shop: Record "Shpfy Shop"; + SkippedRecord: Codeunit "Shpfy Skipped Record"; + begin + if Shop.Get(OrderTransaction.Shop) then + SkippedRecord.LogSkippedRecord(OrderTransaction."Shopify Transaction Id", OrderTransaction.RecordId, CopyStr(FailureReason, 1, 250), Shop); + end; +} diff --git a/src/Apps/W1/Shopify/App/src/Transactions/Codeunits/ShpfyAutoPostTransactions.Codeunit.al b/src/Apps/W1/Shopify/App/src/Transactions/Codeunits/ShpfyAutoPostTransactions.Codeunit.al new file mode 100644 index 00000000000..c2caaf5ffae --- /dev/null +++ b/src/Apps/W1/Shopify/App/src/Transactions/Codeunits/ShpfyAutoPostTransactions.Codeunit.al @@ -0,0 +1,217 @@ +// ------------------------------------------------------------------------------------------------ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. See License.txt in the project root for license information. +// ------------------------------------------------------------------------------------------------ + +namespace Microsoft.Integration.Shopify; + +using Microsoft.Finance.GeneralLedger.Journal; +using Microsoft.Finance.GeneralLedger.Posting; +using Microsoft.Sales.Document; +using Microsoft.Sales.History; + +/// +/// Codeunit Shpfy Auto Post Transactions (ID 30236). +/// Automatically posts Shopify order and refund payment transactions as general journal lines when +/// the related sales invoice or credit memo is posted, provided the transaction's payment method +/// mapping is configured for automatic posting. Posting is synchronous and best-effort: a failure to +/// post a payment is logged as a skipped record and never blocks or reverses the document posting. +/// Each transaction is posted through a dedicated, single-use journal batch so that only the generated +/// lines are posted and pre-existing lines in the configured batch are never touched. +/// +codeunit 30236 "Shpfy Auto Post Transactions" +{ + Access = Internal; + + internal procedure AutoPostTransactions(SalesInvoiceHeaderNo: Code[20]; SalesCrMemoHeaderNo: Code[20]; HasJournalPermissions: Boolean) + begin + if SalesInvoiceHeaderNo <> '' then + PostOrderTransactions(SalesInvoiceHeaderNo, HasJournalPermissions); + if SalesCrMemoHeaderNo <> '' then + PostRefundTransactions(SalesCrMemoHeaderNo, HasJournalPermissions); + end; + + local procedure PostOrderTransactions(SalesInvoiceHeaderNo: Code[20]; HasJournalPermissions: Boolean) + var + SalesInvoiceHeader: Record "Sales Invoice Header"; + OrderTransaction: Record "Shpfy Order Transaction"; + begin + if not SalesInvoiceHeader.Get(SalesInvoiceHeaderNo) then + exit; + if SalesInvoiceHeader."Shpfy Order Id" = 0 then + exit; + + OrderTransaction.SetRange("Shopify Order Id", SalesInvoiceHeader."Shpfy Order Id"); + OrderTransaction.SetFilter(Type, '%1|%2', OrderTransaction.Type::Capture, OrderTransaction.Type::Sale); + PostTransactions(OrderTransaction, SalesInvoiceHeader."Posting Date", HasJournalPermissions); + end; + + local procedure PostRefundTransactions(SalesCrMemoHeaderNo: Code[20]; HasJournalPermissions: Boolean) + var + SalesCrMemoHeader: Record "Sales Cr.Memo Header"; + OrderTransaction: Record "Shpfy Order Transaction"; + begin + if not SalesCrMemoHeader.Get(SalesCrMemoHeaderNo) then + exit; + if SalesCrMemoHeader."Shpfy Refund Id" = 0 then + exit; + + OrderTransaction.SetRange("Refund Id", SalesCrMemoHeader."Shpfy Refund Id"); + OrderTransaction.SetRange(Type, OrderTransaction.Type::Refund); + PostTransactions(OrderTransaction, SalesCrMemoHeader."Posting Date", HasJournalPermissions); + end; + + local procedure PostTransactions(var OrderTransaction: Record "Shpfy Order Transaction"; PostingDate: Date; HasJournalPermissions: Boolean) + var + PaymentMethodMapping: Record "Shpfy Payment Method Mapping"; + AutoGenJnlPost: Codeunit "Shpfy Auto Gen. Jnl.-Post"; + AutoPostEligibility: Codeunit "Shpfy Auto Post Eligibility"; + begin + OrderTransaction.SetRange(Status, OrderTransaction.Status::Success); + OrderTransaction.SetRange(Used, false); + if not OrderTransaction.FindSet() then + exit; + + repeat + if AutoPostEligibility.GetPaymentMethodMapping(OrderTransaction, PaymentMethodMapping) then + if PaymentMethodMapping."Post Automatically" then + if not AutoPostEligibility.IsMappingConfigured(PaymentMethodMapping) then + RecordFailure(OrderTransaction, '', '', ConfigurationStageTok, IncompleteSetupReasonLbl, '') + else + if AutoPostEligibility.IsTransactionPostable(OrderTransaction) then + if HasJournalPermissions then + PostTransaction(AutoGenJnlPost, OrderTransaction, PaymentMethodMapping, PostingDate) + else + RecordFailure(OrderTransaction, '', '', AuthorizationStageTok, InsufficientPermissionsReasonLbl, ''); + until OrderTransaction.Next() = 0; + end; + + local procedure PostTransaction(var AutoGenJnlPost: Codeunit "Shpfy Auto Gen. Jnl.-Post"; OrderTransaction: Record "Shpfy Order Transaction"; PaymentMethodMapping: Record "Shpfy Payment Method Mapping"; PostingDate: Date) + var + GenJournalLine: Record "Gen. Journal Line"; + GenJnlPostBatch: Codeunit "Gen. Jnl.-Post Batch"; + TemplateName: Code[10]; + BatchName: Code[10]; + ErrorText: Text; + ErrorCallStack: Text; + BuildSucceeded: Boolean; + PostingSucceeded: Boolean; + begin + AutoGenJnlPost.SetParameters(PaymentMethodMapping, PostingDate); + BindSubscription(AutoGenJnlPost); + BuildSucceeded := AutoGenJnlPost.Run(OrderTransaction); + if not BuildSucceeded then begin + ErrorText := GetLastErrorText(); + ErrorCallStack := GetLastErrorCallStack(); + end; + UnbindSubscription(AutoGenJnlPost); + if not BuildSucceeded then begin + RecordFailure(OrderTransaction, '', '', BuildStageTok, ErrorText, ErrorCallStack); + exit; + end; + + AutoGenJnlPost.GetIsolatedBatch(TemplateName, BatchName); + if BatchName = '' then + exit; + + GenJournalLine.SetRange("Journal Template Name", TemplateName); + GenJournalLine.SetRange("Journal Batch Name", BatchName); + if not GenJournalLine.FindSet() then begin + CleanupBatch(OrderTransaction, TemplateName, BatchName); + exit; + end; + + BindSubscription(AutoGenJnlPost); + PostingSucceeded := GenJnlPostBatch.Run(GenJournalLine); + if not PostingSucceeded then begin + ErrorText := GetLastErrorText(); + ErrorCallStack := GetLastErrorCallStack(); + end; + UnbindSubscription(AutoGenJnlPost); + if PostingSucceeded then begin + CleanupBatch(OrderTransaction, TemplateName, BatchName); + exit; + end; + + RecordFailure(OrderTransaction, TemplateName, BatchName, PostingStageTok, ErrorText, ErrorCallStack); + end; + + local procedure RecordFailure(OrderTransaction: Record "Shpfy Order Transaction"; TemplateName: Code[10]; BatchName: Code[10]; Stage: Text; ErrorText: Text; ErrorCallStack: Text) + begin + LogFailureTelemetry(OrderTransaction, TemplateName, BatchName, Stage, ErrorText, ErrorCallStack); + if BatchName <> '' then + CleanupBatch(OrderTransaction, TemplateName, BatchName); + PersistFailure(OrderTransaction, TemplateName, BatchName, ErrorText); + end; + + local procedure CleanupBatch(OrderTransaction: Record "Shpfy Order Transaction"; TemplateName: Code[10]; BatchName: Code[10]) + var + AutoPostFinalize: Codeunit "Shpfy Auto Post Finalize"; + FinalizeErrorText: Text; + FinalizeErrorCallStack: Text; + begin + AutoPostFinalize.SetCleanupParameters(TemplateName, BatchName); + if AutoPostFinalize.Run(OrderTransaction) then + exit; + + FinalizeErrorText := GetLastErrorText(); + FinalizeErrorCallStack := GetLastErrorCallStack(); + LogFinalizationFailureTelemetry(OrderTransaction, TemplateName, BatchName, CleanupStageTok, FinalizeErrorText, FinalizeErrorCallStack); + end; + + local procedure PersistFailure(OrderTransaction: Record "Shpfy Order Transaction"; TemplateName: Code[10]; BatchName: Code[10]; ErrorText: Text) + var + AutoPostFinalize: Codeunit "Shpfy Auto Post Finalize"; + FinalizeErrorText: Text; + FinalizeErrorCallStack: Text; + begin + AutoPostFinalize.SetFailureParameters(ErrorText); + if AutoPostFinalize.Run(OrderTransaction) then + exit; + + FinalizeErrorText := GetLastErrorText(); + FinalizeErrorCallStack := GetLastErrorCallStack(); + LogFinalizationFailureTelemetry(OrderTransaction, TemplateName, BatchName, FailureLogStageTok, FinalizeErrorText, FinalizeErrorCallStack); + end; + + local procedure LogFailureTelemetry(OrderTransaction: Record "Shpfy Order Transaction"; TemplateName: Code[10]; BatchName: Code[10]; Stage: Text; ErrorText: Text; ErrorCallStack: Text) + var + CustomDimensions: Dictionary of [Text, Text]; + begin + AddTelemetryDimensions(CustomDimensions, OrderTransaction, TemplateName, BatchName, Stage, ErrorText, ErrorCallStack); + Session.LogMessage('0000S2A', AutoPostFailedTelemetryMsg, Verbosity::Warning, DataClassification::CustomerContent, TelemetryScope::ExtensionPublisher, CustomDimensions); + end; + + local procedure LogFinalizationFailureTelemetry(OrderTransaction: Record "Shpfy Order Transaction"; TemplateName: Code[10]; BatchName: Code[10]; Stage: Text; ErrorText: Text; ErrorCallStack: Text) + var + CustomDimensions: Dictionary of [Text, Text]; + begin + AddTelemetryDimensions(CustomDimensions, OrderTransaction, TemplateName, BatchName, Stage, ErrorText, ErrorCallStack); + Session.LogMessage('0000S2B', AutoPostFinalizationFailedTelemetryMsg, Verbosity::Warning, DataClassification::CustomerContent, TelemetryScope::ExtensionPublisher, CustomDimensions); + end; + + local procedure AddTelemetryDimensions(var CustomDimensions: Dictionary of [Text, Text]; OrderTransaction: Record "Shpfy Order Transaction"; TemplateName: Code[10]; BatchName: Code[10]; Stage: Text; ErrorText: Text; ErrorCallStack: Text) + begin + CustomDimensions.Add('Category', CategoryTok); + CustomDimensions.Add('Stage', Stage); + CustomDimensions.Add('Shop Code', OrderTransaction.Shop); + CustomDimensions.Add('Shopify Transaction Id', Format(OrderTransaction."Shopify Transaction Id")); + CustomDimensions.Add('Journal Template Name', TemplateName); + CustomDimensions.Add('Journal Batch Name', BatchName); + CustomDimensions.Add('Error Text', CopyStr(ErrorText, 1, 250)); + CustomDimensions.Add('Error Call Stack', CopyStr(ErrorCallStack, 1, 2048)); + end; + + var + AutoPostFailedTelemetryMsg: Label 'Automatic Shopify transaction posting failed.', Locked = true; + AutoPostFinalizationFailedTelemetryMsg: Label 'Finalizing a failed automatic Shopify transaction posting attempt failed.', Locked = true; + IncompleteSetupReasonLbl: Label 'Automatic posting is enabled, but the journal template or journal batch is not configured.'; + InsufficientPermissionsReasonLbl: Label 'Automatic posting requires permission to read and write general journal batches and lines.'; + CategoryTok: Label 'Shopify Integration', Locked = true; + AuthorizationStageTok: Label 'Authorization', Locked = true; + BuildStageTok: Label 'Build journal lines', Locked = true; + CleanupStageTok: Label 'Cleanup journal batch', Locked = true; + ConfigurationStageTok: Label 'Configuration', Locked = true; + FailureLogStageTok: Label 'Log skipped record', Locked = true; + PostingStageTok: Label 'Post journal batch', Locked = true; +} diff --git a/src/Apps/W1/Shopify/App/src/Transactions/Pages/ShpfyFilterTransactions.Page.al b/src/Apps/W1/Shopify/App/src/Transactions/Pages/ShpfyFilterTransactions.Page.al new file mode 100644 index 00000000000..194ebbcca1c --- /dev/null +++ b/src/Apps/W1/Shopify/App/src/Transactions/Pages/ShpfyFilterTransactions.Page.al @@ -0,0 +1,70 @@ +// ------------------------------------------------------------------------------------------------ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. See License.txt in the project root for license information. +// ------------------------------------------------------------------------------------------------ + +namespace Microsoft.Integration.Shopify; + +/// +/// Page Shpfy Filter Transactions (ID 30176). +/// Request dialog used to filter the Shopify transactions list down to the transactions that are +/// ready to be posted, optionally narrowed by payment gateway and creation date range. +/// +page 30176 "Shpfy Filter Transactions" +{ + Caption = 'Filter Postable Transactions'; + PageType = StandardDialog; + ApplicationArea = All; + + layout + { + area(Content) + { + field(Gateway; Gateway) + { + Caption = 'Gateway'; + ToolTip = 'Specifies the transaction gateway to filter transactions by. Leave blank to include all gateways set up for automatic posting.'; + Editable = false; + + trigger OnAssistEdit() + var + PaymentMethodMapping: Record "Shpfy Payment Method Mapping"; + begin + PaymentMethodMapping.SetRange("Post Automatically", true); + if Page.RunModal(Page::"Shpfy Payment Methods Mapping", PaymentMethodMapping) = Action::LookupOK then begin + ShopCode := PaymentMethodMapping."Shop Code"; + Gateway := PaymentMethodMapping.Gateway; + CreditCardCompany := PaymentMethodMapping."Credit Card Company"; + end; + end; + } + field(StartDate; StartDate) + { + Caption = 'Start Date'; + ToolTip = 'Specifies the earliest transaction creation date to include in the filter. Leave blank to include all transactions from the beginning.'; + } + field(EndDate; EndDate) + { + Caption = 'End Date'; + ToolTip = 'Specifies the latest transaction creation date to include in the filter. Leave blank to include all transactions.'; + } + } + } + + var + ShopCode: Code[20]; + Gateway: Text[30]; + CreditCardCompany: Text[50]; + StartDate: Date; + EndDate: Date; + + internal procedure GetParameters(var NewShopCode: Code[20]; var NewGateway: Text[30]; var NewCreditCardCompany: Text[50]; var NewStartDate: DateTime; var NewEndDate: DateTime) + begin + NewShopCode := ShopCode; + NewGateway := Gateway; + NewCreditCardCompany := CreditCardCompany; + NewStartDate := CreateDateTime(StartDate, 0T); + if EndDate <> 0D then + NewEndDate := CreateDateTime(EndDate, 235959.999T); + end; +} diff --git a/src/Apps/W1/Shopify/App/src/Transactions/Pages/ShpfyPaymentMethodsMapping.Page.al b/src/Apps/W1/Shopify/App/src/Transactions/Pages/ShpfyPaymentMethodsMapping.Page.al index e5cab053edf..81d1e808229 100644 --- a/src/Apps/W1/Shopify/App/src/Transactions/Pages/ShpfyPaymentMethodsMapping.Page.al +++ b/src/Apps/W1/Shopify/App/src/Transactions/Pages/ShpfyPaymentMethodsMapping.Page.al @@ -37,6 +37,23 @@ page 30132 "Shpfy Payment Methods Mapping" ApplicationArea = All; ToolTip = 'Specifies the corresponding payment method in D365BC.'; } + field(AutoPostJnlTemplate; Rec."Auto-Post Jnl. Template") + { + ApplicationArea = All; + ToolTip = 'Specifies the general journal template to use for automatically posting payment transactions.'; + ShowMandatory = Rec."Post Automatically"; + } + field(AutoPostJnlBatch; Rec."Auto-Post Jnl. Batch") + { + ApplicationArea = All; + ToolTip = 'Specifies the general journal batch to use for automatically posting payment transactions.'; + ShowMandatory = Rec."Post Automatically"; + } + field(PostAutomatically; Rec."Post Automatically") + { + ApplicationArea = All; + ToolTip = 'Specifies whether payment transactions using this gateway should be automatically posted when the related invoice or credit memo is posted. Set the journal template and batch first.'; + } } } } diff --git a/src/Apps/W1/Shopify/App/src/Transactions/Pages/ShpfyTransactions.Page.al b/src/Apps/W1/Shopify/App/src/Transactions/Pages/ShpfyTransactions.Page.al index c76f6158394..8cf3f446d66 100644 --- a/src/Apps/W1/Shopify/App/src/Transactions/Pages/ShpfyTransactions.Page.al +++ b/src/Apps/W1/Shopify/App/src/Transactions/Pages/ShpfyTransactions.Page.al @@ -5,6 +5,7 @@ namespace Microsoft.Integration.Shopify; +using Microsoft.Sales.History; using Microsoft.Sales.Receivables; /// @@ -142,6 +143,11 @@ page 30134 "Shpfy Transactions" ApplicationArea = All; ToolTip = 'Specifies the Posted Invoice number to which the transaction relates.'; } + field("Auto-Post Enabled"; Rec."Auto-Post Enabled") + { + ApplicationArea = All; + ToolTip = 'Specifies whether the transaction is automatically posted when the related invoice or credit memo is posted.'; + } } } } @@ -199,12 +205,39 @@ page 30134 "Shpfy Transactions" SuggestPayments.Run(); end; } + action(ShowPostableTransactions) + { + ApplicationArea = All; + Caption = 'Filter Postable Transactions'; + Image = FilterLines; + ToolTip = 'Show transactions that are ready to be posted: auto-post enabled, successful, not yet posted, and linked to a posted invoice or credit memo. Optionally filter by gateway and date range.'; + + trigger OnAction() + begin + FilterPostableTransactions(); + end; + } + action(ClearFilter) + { + ApplicationArea = All; + Caption = 'Clear Filter'; + Image = ClearFilter; + ToolTip = 'Remove the postable transactions filter and show all transactions.'; + + trigger OnAction() + begin + Rec.ClearMarks(); + Rec.MarkedOnly(false); + end; + } } area(Promoted) { group(Category_Process) { actionref(SuggestShopifyPayments_Promoted; SuggestShopifyPayments) { } + actionref(ShowPostableTransactions_Promoted; ShowPostableTransactions) { } + actionref(ClearFilter_Promoted; ClearFilter) { } } group(Category_Inspect) { @@ -237,4 +270,65 @@ page 30134 "Shpfy Transactions" PresentmentCurrencyVisible := OrderHeader.IsPresentmentCurrencyOrder(); end; + + local procedure FilterPostableTransactions() + var + FilterTransactions: Page "Shpfy Filter Transactions"; + FilterShopCode: Code[20]; + FilterGateway: Text[30]; + FilterCreditCardCompany: Text[50]; + FilterStartDate: DateTime; + FilterEndDate: DateTime; + begin + if FilterTransactions.RunModal() <> Action::OK then + exit; + + FilterTransactions.GetParameters(FilterShopCode, FilterGateway, FilterCreditCardCompany, FilterStartDate, FilterEndDate); + + // Apply the same eligibility predicates the posting routine uses. + Rec.SetRange(Used, false); + Rec.SetRange(Status, Rec.Status::Success); + Rec.SetFilter(Type, '%1|%2|%3', Rec.Type::Capture, Rec.Type::Sale, Rec.Type::Refund); + if (FilterStartDate <> 0DT) or (FilterEndDate <> 0DT) then + Rec.SetRange("Created At", FilterStartDate, GetEndDateFilter(FilterEndDate)); + if FilterGateway <> '' then begin + Rec.SetRange(Shop, FilterShopCode); + Rec.SetRange(Gateway, FilterGateway); + Rec.SetRange("Credit Card Company", FilterCreditCardCompany); + end; + + Rec.ClearMarks(); + Rec.MarkedOnly(false); + MarkPostableTransactions(); + + Rec.MarkedOnly(true); + Rec.SetRange(Shop); + Rec.SetRange(Gateway); + Rec.SetRange("Credit Card Company"); + Rec.SetRange("Created At"); + Rec.SetRange(Used); + Rec.SetRange(Status); + Rec.SetRange(Type); + end; + + local procedure GetEndDateFilter(FilterEndDate: DateTime): DateTime + begin + if FilterEndDate <> 0DT then + exit(FilterEndDate); + exit(CreateDateTime(DMY2Date(31, 12, 9999), 0T)); + end; + + local procedure MarkPostableTransactions() + var + PaymentMethodMapping: Record "Shpfy Payment Method Mapping"; + AutoPostEligibility: Codeunit "Shpfy Auto Post Eligibility"; + begin + Rec.SetLoadFields("Shopify Order Id", Shop, Gateway, "Credit Card Company", Type, Status, "Refund Id"); + if not Rec.FindSet() then + exit; + repeat + if AutoPostEligibility.IsReadyToPost(Rec, PaymentMethodMapping) then + Rec.Mark(true); + until Rec.Next() = 0; + end; } \ No newline at end of file diff --git a/src/Apps/W1/Shopify/App/src/Transactions/Reports/ShpfySuggestPayments.Report.al b/src/Apps/W1/Shopify/App/src/Transactions/Reports/ShpfySuggestPayments.Report.al index 8f95f488356..2f657461f5b 100644 --- a/src/Apps/W1/Shopify/App/src/Transactions/Reports/ShpfySuggestPayments.Report.al +++ b/src/Apps/W1/Shopify/App/src/Transactions/Reports/ShpfySuggestPayments.Report.al @@ -210,6 +210,16 @@ report 30118 "Shpfy Suggest Payments" IgnorePostedTransactions := NewIgnorePostedTransactions; end; + internal procedure SetJournalParameters(NewTemplateName: Code[10]; NewBatchName: Code[10]; NewPostingDate: Date) + begin + GeneralJournalTemplateName := NewTemplateName; + GeneralJournalBatchName := NewBatchName; + GenJournalLine."Journal Template Name" := NewTemplateName; + GenJournalLine."Journal Batch Name" := NewBatchName; + PostingDate := NewPostingDate; + ValidatePostingDate(); + end; + local procedure ValidatePostingDate() var NoSeries: Codeunit "No. Series"; @@ -252,7 +262,7 @@ report 30118 "Shpfy Suggest Payments" repeat if SalesInvoiceHeader.Closed then continue; - ApplyCustomerLedgerEntries(SalesInvoiceHeader."No.", "Gen. Journal Document Type"::Invoice, AmountToApply, Applied); + ApplyCustomerLedgerEntries(OrderTransaction, SalesInvoiceHeader."No.", "Gen. Journal Document Type"::Invoice, AmountToApply, Applied); until SalesInvoiceHeader.Next() = 0 else begin DocLinkToDoc.SetRange("Shopify Document Type", DocLinkToDoc."Shopify Document Type"::"Shopify Shop Order"); @@ -263,12 +273,12 @@ report 30118 "Shpfy Suggest Payments" SalesInvoiceHeader.Get(DocLinkToDoc."Document No."); if SalesInvoiceHeader.Closed then continue; - ApplyCustomerLedgerEntries(SalesInvoiceHeader."No.", "Gen. Journal Document Type"::Invoice, AmountToApply, Applied); + ApplyCustomerLedgerEntries(OrderTransaction, SalesInvoiceHeader."No.", "Gen. Journal Document Type"::Invoice, AmountToApply, Applied); until DocLinkToDoc.Next() = 0; end; if Applied and (AmountToApply > 0) then - CreateSuggestPaymentGLAccount(AmountToApply, true); + CreateSuggestPaymentGLAccount(OrderTransaction, AmountToApply, true); end; OrderTransaction.Type::Refund: begin @@ -283,18 +293,18 @@ report 30118 "Shpfy Suggest Payments" repeat if SalesCreditMemoHeader.Paid then continue; - ApplyCustomerLedgerEntries(SalesCreditMemoHeader."No.", "Gen. Journal Document Type"::"Credit Memo", AmountToApply, Applied); + ApplyCustomerLedgerEntries(OrderTransaction, SalesCreditMemoHeader."No.", "Gen. Journal Document Type"::"Credit Memo", AmountToApply, Applied); until SalesCreditMemoHeader.Next() = 0; until RefundHeader.Next() = 0; if Applied and (AmountToApply > 0) then - CreateSuggestPaymentGLAccount(AmountToApply, false); + CreateSuggestPaymentGLAccount(OrderTransaction, AmountToApply, false); end; end; end; end; - local procedure ApplyCustomerLedgerEntries(DocumentNo: Code[20]; DocumentType: Enum "Gen. Journal Document Type"; var AmountToApply: Decimal; var Applied: Boolean) + local procedure ApplyCustomerLedgerEntries(OrderTransaction: Record "Shpfy Order Transaction"; DocumentNo: Code[20]; DocumentType: Enum "Gen. Journal Document Type"; var AmountToApply: Decimal; var Applied: Boolean) var CustLedgerEntry: Record "Cust. Ledger Entry"; begin @@ -307,12 +317,12 @@ report 30118 "Shpfy Suggest Payments" if CustLedgerEntry.FindSet() then begin Applied := true; repeat - CreateSuggestPaymentDocument(CustLedgerEntry, AmountToApply, DocumentType = "Gen. Journal Document Type"::Invoice); + CreateSuggestPaymentDocument(CustLedgerEntry, OrderTransaction, AmountToApply, DocumentType = "Gen. Journal Document Type"::Invoice); until CustLedgerEntry.Next() = 0; end; end; - local procedure CreateSuggestPaymentDocument(var CustLedgerEntry: Record "Cust. Ledger Entry"; var AmountToApply: Decimal; IsInvoice: Boolean) + local procedure CreateSuggestPaymentDocument(var CustLedgerEntry: Record "Cust. Ledger Entry"; OrderTransaction: Record "Shpfy Order Transaction"; var AmountToApply: Decimal; IsInvoice: Boolean) begin TempSuggestPayment.Init(); EntryNo += 1; @@ -346,7 +356,7 @@ report 30118 "Shpfy Suggest Payments" TempSuggestPayment.Insert(); end; - local procedure CreateSuggestPaymentGLAccount(AmountToApply: Decimal; IsInvoice: Boolean) + local procedure CreateSuggestPaymentGLAccount(OrderTransaction: Record "Shpfy Order Transaction"; AmountToApply: Decimal; IsInvoice: Boolean) begin TempSuggestPayment.Init(); EntryNo += 1; diff --git a/src/Apps/W1/Shopify/App/src/Transactions/Tables/ShpfyOrderTransaction.Table.al b/src/Apps/W1/Shopify/App/src/Transactions/Tables/ShpfyOrderTransaction.Table.al index 8099a2abca1..6fbabb55fe2 100644 --- a/src/Apps/W1/Shopify/App/src/Transactions/Tables/ShpfyOrderTransaction.Table.al +++ b/src/Apps/W1/Shopify/App/src/Transactions/Tables/ShpfyOrderTransaction.Table.al @@ -209,6 +209,17 @@ table 30133 "Shpfy Order Transaction" DataClassification = SystemMetadata; Editable = false; } + field(100; "Auto-Post Enabled"; Boolean) + { + Caption = 'Auto-Post Enabled'; + FieldClass = FlowField; + CalcFormula = exist("Shpfy Payment Method Mapping" where("Shop Code" = field("Shop"), + Gateway = field(Gateway), + "Credit Card Company" = field("Credit Card Company"), + "Post Automatically" = const(true), + "Auto-Post Jnl. Template" = filter(<> ''), + "Auto-Post Jnl. Batch" = filter(<> ''))); + } field(101; "Sales Document No."; code[20]) { Caption = 'Sales Document No.'; diff --git a/src/Apps/W1/Shopify/App/src/Transactions/Tables/ShpfyPaymentMethodMapping.Table.al b/src/Apps/W1/Shopify/App/src/Transactions/Tables/ShpfyPaymentMethodMapping.Table.al index e1ee2fb335f..0d0e5afab62 100644 --- a/src/Apps/W1/Shopify/App/src/Transactions/Tables/ShpfyPaymentMethodMapping.Table.al +++ b/src/Apps/W1/Shopify/App/src/Transactions/Tables/ShpfyPaymentMethodMapping.Table.al @@ -6,6 +6,7 @@ namespace Microsoft.Integration.Shopify; using Microsoft.Bank.BankAccount; +using Microsoft.Finance.GeneralLedger.Journal; /// /// Table Shpfy Payment Method Mapping (ID 30134). @@ -15,6 +16,8 @@ table 30134 "Shpfy Payment Method Mapping" Access = Internal; Caption = 'Shopify Payment Method'; DataClassification = CustomerContent; + DrillDownPageId = "Shpfy Payment Methods Mapping"; + LookupPageId = "Shpfy Payment Methods Mapping"; fields { @@ -30,7 +33,7 @@ table 30134 "Shpfy Payment Method Mapping" DataClassification = CustomerContent; TableRelation = "Shpfy Transaction Gateway"; } - field(3; "Credit Card Company"; Text[30]) + field(3; "Credit Card Company"; Text[50]) { Caption = 'Credit Card Company'; DataClassification = CustomerContent; @@ -59,6 +62,52 @@ table 30134 "Shpfy Payment Method Mapping" DataClassification = SystemMetadata; Editable = false; } + field(7; "Post Automatically"; Boolean) + { + Caption = 'Post Automatically'; + DataClassification = SystemMetadata; + + trigger OnValidate() + begin + if "Post Automatically" then begin + TestField("Auto-Post Jnl. Template"); + TestField("Auto-Post Jnl. Batch"); + end; + end; + } + field(8; "Auto-Post Jnl. Template"; Code[10]) + { + Caption = 'Auto-Post Journal Template'; + DataClassification = SystemMetadata; + TableRelation = "Gen. Journal Template" where(Type = const("Cash Receipts")); + + trigger OnValidate() + begin + if "Auto-Post Jnl. Template" <> xRec."Auto-Post Jnl. Template" then begin + Clear("Auto-Post Jnl. Batch"); + "Post Automatically" := false; + end; + end; + } + field(9; "Auto-Post Jnl. Batch"; Code[10]) + { + Caption = 'Auto-Post Journal Batch'; + DataClassification = SystemMetadata; + TableRelation = "Gen. Journal Batch".Name where("Journal Template Name" = field("Auto-Post Jnl. Template")); + + trigger OnValidate() + var + GenJournalBatch: Record "Gen. Journal Batch"; + begin + if "Auto-Post Jnl. Batch" = '' then begin + "Post Automatically" := false; + exit; + end; + TestField("Auto-Post Jnl. Template"); + GenJournalBatch.Get("Auto-Post Jnl. Template", "Auto-Post Jnl. Batch"); + GenJournalBatch.TestField("Bal. Account No."); + end; + } } keys { @@ -68,4 +117,21 @@ table 30134 "Shpfy Payment Method Mapping" } } + trigger OnInsert() + begin + ValidateAutoPostSetup(); + end; + + trigger OnModify() + begin + ValidateAutoPostSetup(); + end; + + local procedure ValidateAutoPostSetup() + begin + if not "Post Automatically" then + exit; + TestField("Auto-Post Jnl. Template"); + TestField("Auto-Post Jnl. Batch"); + end; } \ No newline at end of file diff --git a/src/Apps/W1/Shopify/Test/Payments/ShpfyAutoPostTransTest.Codeunit.al b/src/Apps/W1/Shopify/Test/Payments/ShpfyAutoPostTransTest.Codeunit.al new file mode 100644 index 00000000000..83c0fb8f4f0 --- /dev/null +++ b/src/Apps/W1/Shopify/Test/Payments/ShpfyAutoPostTransTest.Codeunit.al @@ -0,0 +1,997 @@ +// ------------------------------------------------------------------------------------------------ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. See License.txt in the project root for license information. +// ------------------------------------------------------------------------------------------------ + +namespace Microsoft.Integration.Shopify.Test; + +using Microsoft.Finance.GeneralLedger.Account; +using Microsoft.Finance.GeneralLedger.Journal; +using Microsoft.Finance.GeneralLedger.Preview; +using Microsoft.Finance.GeneralLedger.Setup; +using Microsoft.Foundation.AuditCodes; +using Microsoft.Foundation.NoSeries; +using Microsoft.Integration.Shopify; +using Microsoft.Inventory.Item; +using Microsoft.Sales.Customer; +using Microsoft.Sales.Document; +using Microsoft.Sales.History; +using Microsoft.Sales.Posting; +using Microsoft.Sales.Receivables; +using Microsoft.Utilities; +using System.Environment.Configuration; +using System.TestLibraries.Utilities; + +/// +/// Codeunit Shpfy Auto Post Trans. Test (ID 139415). +/// +codeunit 139415 "Shpfy Auto Post Trans. Test" +{ + Subtype = Test; + TestType = IntegrationTest; + TestPermissions = Disabled; + + var + Customer: Record Customer; + Item: Record Item; + Shop: Record "Shpfy Shop"; + PaymentMethodMapping: Record "Shpfy Payment Method Mapping"; + LibraryAssert: Codeunit "Library Assert"; + LibraryERM: Codeunit "Library - ERM"; + LibraryRandom: Codeunit "Library - Random"; + LibrarySales: Codeunit "Library - Sales"; + IsInitialized: Boolean; + + [Test] + procedure UnitTestAutoPostJnlBatchValidateWithBalAccountNo() + var + ShpfyPaymentMethodMapping: Record "Shpfy Payment Method Mapping"; + GenJournalBatch: Record "Gen. Journal Batch"; + begin + // [SCENARIO] Auto-Post Jnl. Batch field validates successfully when the journal batch has a balancing account number + + // [GIVEN] A Gen. Journal Batch with a balancing account number + CreateJournalBatch(GenJournalBatch); + ShpfyPaymentMethodMapping."Auto-Post Jnl. Template" := GenJournalBatch."Journal Template Name"; + + // [WHEN] Auto-Post Jnl. Batch is validated + ShpfyPaymentMethodMapping.Validate("Auto-Post Jnl. Batch", GenJournalBatch.Name); + + // [THEN] Validation passes without error + LibraryAssert.AreEqual(GenJournalBatch.Name, ShpfyPaymentMethodMapping."Auto-Post Jnl. Batch", 'Auto-Post Jnl. Batch should be set'); + end; + + [Test] + procedure UnitTestAutoPostJnlBatchValidateWithoutBalAccountNo() + var + ShpfyPaymentMethodMapping: Record "Shpfy Payment Method Mapping"; + GenJournalBatch: Record "Gen. Journal Batch"; + begin + // [SCENARIO] Auto-Post Jnl. Batch field validation fails when the journal batch does not have a balancing account number + + // [GIVEN] A Gen. Journal Batch without a balancing account number + CreateJournalBatch(GenJournalBatch); + GenJournalBatch."Bal. Account No." := ''; + GenJournalBatch.Modify(); + ShpfyPaymentMethodMapping."Auto-Post Jnl. Template" := GenJournalBatch."Journal Template Name"; + + // [WHEN] Auto-Post Jnl. Batch is validated + // [THEN] Validation fails with the missing balancing-account error + asserterror ShpfyPaymentMethodMapping.Validate("Auto-Post Jnl. Batch", GenJournalBatch.Name); + LibraryAssert.ExpectedTestFieldError(GenJournalBatch.FieldCaption("Bal. Account No."), ''); + end; + + [Test] + procedure UnitTestAutoPostJnlBatchValidateWithEmptyValue() + var + ShpfyPaymentMethodMapping: Record "Shpfy Payment Method Mapping"; + begin + // [SCENARIO] Auto-Post Jnl. Batch field can be set to empty without a validation error + + // [WHEN] Auto-Post Jnl. Batch is set to empty + ShpfyPaymentMethodMapping.Validate("Auto-Post Jnl. Batch", ''); + + // [THEN] Validation passes without error + LibraryAssert.AreEqual('', ShpfyPaymentMethodMapping."Auto-Post Jnl. Batch", 'Auto-Post Jnl. Batch should be empty'); + end; + + [Test] + procedure UnitTestAutoPostRequiresJournalSetup() + var + ShpfyPaymentMethodMapping: Record "Shpfy Payment Method Mapping"; + begin + // [SCENARIO] Automatic posting cannot be enabled without a configured journal template and batch + + // [WHEN] Post Automatically is enabled without journal setup + // [THEN] Validation fails on the missing journal template + asserterror ShpfyPaymentMethodMapping.Validate("Post Automatically", true); + LibraryAssert.ExpectedTestFieldError(ShpfyPaymentMethodMapping.FieldCaption("Auto-Post Jnl. Template"), ''); + end; + + [Test] + procedure UnitTestPostSalesOrderWithAutoPostTransaction() + var + SalesHeader: Record "Sales Header"; + CustLedgerEntry: Record "Cust. Ledger Entry"; + OrderId: BigInteger; + TransactionId: BigInteger; + begin + // [SCENARIO] When an invoice with a Shopify Order Id is posted and Post Automatically is true, the transaction is auto-posted + + // [GIVEN] Initialized test environment + Initialize(); + + // [GIVEN] A Shopify order with a transaction + OrderId := LibraryRandom.RandIntInRange(1000000, 1999999); + TransactionId := LibraryRandom.RandIntInRange(1000000, 1999999); + CreateShopifyOrder(OrderId); + CreateOrderTransaction(TransactionId, OrderId, 0, PaymentMethodMapping.Gateway, Enum::"Shpfy Transaction Type"::Sale, Item."Unit Price"); + + // [GIVEN] Payment method mapping with auto-post enabled + EnablePaymentMethodMappingAutoPost(true); + + // [GIVEN] A sales invoice with a Shopify Order Id + CreateSalesOrder(SalesHeader, OrderId); + + // [WHEN] The sales invoice is posted + LibrarySales.PostSalesDocument(SalesHeader, true, true); + + // [THEN] A Cust. Ledger Entry is created for the transaction + CustLedgerEntry.SetRange("Shpfy Transaction Id", TransactionId); + LibraryAssert.IsFalse(CustLedgerEntry.IsEmpty(), 'Cust. Ledger Entry should be created for the auto-posted transaction'); + end; + + [Test] + procedure UnitTestPostSalesOrderWithAutoPostCaptureTransaction() + var + SalesHeader: Record "Sales Header"; + CustLedgerEntry: Record "Cust. Ledger Entry"; + OrderId: BigInteger; + TransactionId: BigInteger; + begin + // [SCENARIO] A successful capture transaction is automatically posted with its invoice + Initialize(); + + OrderId := LibraryRandom.RandIntInRange(13000000, 13999999); + TransactionId := LibraryRandom.RandIntInRange(13000000, 13999999); + CreateShopifyOrder(OrderId); + CreateOrderTransaction(TransactionId, OrderId, 0, PaymentMethodMapping.Gateway, Enum::"Shpfy Transaction Type"::Capture, Item."Unit Price"); + EnablePaymentMethodMappingAutoPost(true); + CreateSalesOrder(SalesHeader, OrderId); + + LibrarySales.PostSalesDocument(SalesHeader, true, true); + + CustLedgerEntry.SetRange("Shpfy Transaction Id", TransactionId); + LibraryAssert.IsFalse(CustLedgerEntry.IsEmpty(), 'Cust. Ledger Entry should be created for the capture transaction'); + end; + + [Test] + procedure UnitTestPostSalesOrderWithoutAutoPostTransaction() + var + SalesHeader: Record "Sales Header"; + CustLedgerEntry: Record "Cust. Ledger Entry"; + OrderId: BigInteger; + TransactionId: BigInteger; + begin + // [SCENARIO] When an invoice with a Shopify Order Id is posted and Post Automatically is false, the transaction is not auto-posted + + // [GIVEN] Initialized test environment + Initialize(); + + // [GIVEN] A Shopify order with a transaction + OrderId := LibraryRandom.RandIntInRange(2000000, 2999999); + TransactionId := LibraryRandom.RandIntInRange(2000000, 2999999); + CreateShopifyOrder(OrderId); + CreateOrderTransaction(TransactionId, OrderId, 0, PaymentMethodMapping.Gateway, Enum::"Shpfy Transaction Type"::Sale, Item."Unit Price"); + + // [GIVEN] Payment method mapping with auto-post disabled + EnablePaymentMethodMappingAutoPost(false); + + // [GIVEN] A sales invoice with a Shopify Order Id + CreateSalesOrder(SalesHeader, OrderId); + + // [WHEN] The sales invoice is posted + LibrarySales.PostSalesDocument(SalesHeader, true, true); + + // [THEN] A Cust. Ledger Entry is not created for the transaction + CustLedgerEntry.SetRange("Shpfy Transaction Id", TransactionId); + LibraryAssert.IsTrue(CustLedgerEntry.IsEmpty(), 'Cust. Ledger Entry should not be created for the non-auto-posted transaction'); + end; + + [Test] + procedure UnitTestPostSalesOrderWithAutoPostMultipleTransaction() + var + SalesHeader: Record "Sales Header"; + CustLedgerEntry: Record "Cust. Ledger Entry"; + TransactionId1: BigInteger; + TransactionId2: BigInteger; + OrderId: BigInteger; + begin + // [SCENARIO] When an invoice with a Shopify Order Id is posted and Post Automatically is true, multiple transactions are auto-posted + + // [GIVEN] Initialized test environment + Initialize(); + + // [GIVEN] A Shopify order with multiple transactions + OrderId := LibraryRandom.RandIntInRange(3000000, 3999999); + CreateShopifyOrder(OrderId); + TransactionId1 := LibraryRandom.RandIntInRange(3000000, 3499999); + TransactionId2 := LibraryRandom.RandIntInRange(3500000, 3999999); + CreateOrderTransaction(TransactionId1, OrderId, 0, PaymentMethodMapping.Gateway, Enum::"Shpfy Transaction Type"::Sale, Item."Unit Price" / 2); + CreateOrderTransaction(TransactionId2, OrderId, 0, PaymentMethodMapping.Gateway, Enum::"Shpfy Transaction Type"::Sale, Item."Unit Price" / 2); + + // [GIVEN] Payment method mapping with auto-post enabled + EnablePaymentMethodMappingAutoPost(true); + + // [GIVEN] A sales invoice with a Shopify Order Id + CreateSalesOrder(SalesHeader, OrderId); + + // [WHEN] The sales invoice is posted + LibrarySales.PostSalesDocument(SalesHeader, true, true); + + // [THEN] A Cust. Ledger Entry is created for each transaction + CustLedgerEntry.SetRange("Shpfy Transaction Id", TransactionId1); + LibraryAssert.IsFalse(CustLedgerEntry.IsEmpty(), 'Cust. Ledger Entry should be created for the first auto-posted transaction'); + CustLedgerEntry.SetRange("Shpfy Transaction Id", TransactionId2); + LibraryAssert.IsFalse(CustLedgerEntry.IsEmpty(), 'Cust. Ledger Entry should be created for the second auto-posted transaction'); + end; + + [Test] + procedure UnitTestPostSalesOrderWithMixedTransactions() + var + SalesHeader: Record "Sales Header"; + CustLedgerEntry: Record "Cust. Ledger Entry"; + TransactionId1: BigInteger; + TransactionId2: BigInteger; + OrderId: BigInteger; + begin + // [SCENARIO] Only transactions linked to an auto-post Payment Method Mapping are auto-posted + + // [GIVEN] Initialized test environment + Initialize(); + + // [GIVEN] A Shopify order + OrderId := LibraryRandom.RandIntInRange(4000000, 4999999); + CreateShopifyOrder(OrderId); + + // [GIVEN] Payment method mapping with auto-post enabled + EnablePaymentMethodMappingAutoPost(true); + + // [GIVEN] Transaction linked to an auto-post Payment Method Mapping + TransactionId1 := LibraryRandom.RandIntInRange(4000000, 4499999); + CreateOrderTransaction(TransactionId1, OrderId, 0, PaymentMethodMapping.Gateway, Enum::"Shpfy Transaction Type"::Sale, Item."Unit Price" / 2); + + // [GIVEN] Transaction not linked to any Payment Method Mapping + TransactionId2 := LibraryRandom.RandIntInRange(4500000, 4999999); + CreateOrderTransaction(TransactionId2, OrderId, 0, 'auto post disabled', Enum::"Shpfy Transaction Type"::Sale, Item."Unit Price" / 2); + + // [GIVEN] A sales invoice with a Shopify Order Id + CreateSalesOrder(SalesHeader, OrderId); + + // [WHEN] The sales invoice is posted + LibrarySales.PostSalesDocument(SalesHeader, true, true); + + // [THEN] Only the linked transaction is auto-posted + CustLedgerEntry.SetRange("Shpfy Transaction Id", TransactionId1); + LibraryAssert.IsFalse(CustLedgerEntry.IsEmpty(), 'Cust. Ledger Entry should be created for the auto-posted transaction'); + CustLedgerEntry.SetRange("Shpfy Transaction Id", TransactionId2); + LibraryAssert.IsTrue(CustLedgerEntry.IsEmpty(), 'Cust. Ledger Entry should not be created for the non-linked transaction'); + end; + + [Test] + procedure UnitTestPostCreditMemoWithAutoPostTransaction() + var + SalesHeader: Record "Sales Header"; + CustLedgerEntry: Record "Cust. Ledger Entry"; + TransactionId: BigInteger; + RefundId: BigInteger; + OrderId: BigInteger; + begin + // [SCENARIO] When a credit memo with a Shopify Refund Id is posted and Post Automatically is true, the refund transaction is auto-posted + + // [GIVEN] Initialized test environment + Initialize(); + + // [GIVEN] A refund with a transaction + RefundId := LibraryRandom.RandIntInRange(5000000, 5999999); + OrderId := LibraryRandom.RandIntInRange(5000000, 5999999); + CreateShopifyOrder(OrderId); + CreateRefund(RefundId, OrderId); + TransactionId := LibraryRandom.RandIntInRange(5000000, 5999999); + CreateOrderTransaction(TransactionId, OrderId, RefundId, PaymentMethodMapping.Gateway, Enum::"Shpfy Transaction Type"::Refund, Item."Unit Price"); + + // [GIVEN] Payment method mapping with auto-post enabled + EnablePaymentMethodMappingAutoPost(true); + + // [GIVEN] A sales credit memo with a Shopify Refund Id + CreateCreditMemo(SalesHeader, RefundId); + + // [WHEN] The credit memo is posted + LibrarySales.PostSalesDocument(SalesHeader, true, true); + + // [THEN] A Cust. Ledger Entry is created for the refund transaction + CustLedgerEntry.SetRange("Shpfy Transaction Id", TransactionId); + LibraryAssert.IsFalse(CustLedgerEntry.IsEmpty(), 'Cust. Ledger Entry should be created for the auto-posted refund transaction'); + end; + + [Test] + procedure UnitTestAutoPostWorksWithPostWithJobQueueEnabled() + var + GeneralLedgerSetup: Record "General Ledger Setup"; + SalesHeader: Record "Sales Header"; + CustLedgerEntry: Record "Cust. Ledger Entry"; + OrderId: BigInteger; + TransactionId: BigInteger; + OriginalPostWithJobQueue: Boolean; + begin + // [SCENARIO] Automatic posting is synchronous and works even when "Post with Job Queue" is enabled in the General Ledger Setup + + // [GIVEN] Initialized test environment + Initialize(); + + // [GIVEN] Post with Job Queue is enabled + GeneralLedgerSetup.Get(); + OriginalPostWithJobQueue := GeneralLedgerSetup."Post with Job Queue"; + GeneralLedgerSetup."Post with Job Queue" := true; + GeneralLedgerSetup.Modify(); + + // [GIVEN] A Shopify order with a transaction linked to an auto-post mapping + OrderId := LibraryRandom.RandIntInRange(7000000, 7999999); + TransactionId := LibraryRandom.RandIntInRange(7000000, 7999999); + CreateShopifyOrder(OrderId); + CreateOrderTransaction(TransactionId, OrderId, 0, PaymentMethodMapping.Gateway, Enum::"Shpfy Transaction Type"::Sale, Item."Unit Price"); + EnablePaymentMethodMappingAutoPost(true); + + // [GIVEN] A sales invoice with a Shopify Order Id + CreateSalesOrder(SalesHeader, OrderId); + + // [WHEN] The sales invoice is posted + LibrarySales.PostSalesDocument(SalesHeader, true, true); + + // [THEN] The transaction is posted synchronously (Cust. Ledger Entry exists), it is not scheduled to the job queue + CustLedgerEntry.SetRange("Shpfy Transaction Id", TransactionId); + LibraryAssert.IsFalse(CustLedgerEntry.IsEmpty(), 'Transaction should be posted synchronously even with Post with Job Queue enabled'); + + // Restore the setup (harmless either way, as automatic posting never uses the job queue). + GeneralLedgerSetup.Get(); + GeneralLedgerSetup."Post with Job Queue" := OriginalPostWithJobQueue; + GeneralLedgerSetup.Modify(); + end; + + [Test] + procedure UnitTestPostSalesOrderFailedTransactionIsBestEffort() + var + SalesHeader: Record "Sales Header"; + SalesInvoiceHeader: Record "Sales Invoice Header"; + CustLedgerEntry: Record "Cust. Ledger Entry"; + SkippedRecord: Record "Shpfy Skipped Record"; + OrderTransaction: Record "Shpfy Order Transaction"; + FailingGateway: Text[30]; + OrderId: BigInteger; + TransactionId: BigInteger; + begin + // [SCENARIO] When automatic posting of a transaction fails, the document posting still succeeds and the failure is logged + + // [GIVEN] Initialized test environment + Initialize(); + + // [GIVEN] A payment method mapping whose journal batch cannot post (no number series) + FailingGateway := CreateFailingPaymentMethodMapping(); + + // [GIVEN] A Shopify order with a transaction using the failing mapping + OrderId := LibraryRandom.RandIntInRange(6000000, 6999999); + TransactionId := LibraryRandom.RandIntInRange(6000000, 6999999); + CreateShopifyOrder(OrderId); + CreateOrderTransaction(TransactionId, OrderId, 0, FailingGateway, Enum::"Shpfy Transaction Type"::Sale, Item."Unit Price"); + + // [GIVEN] A sales invoice with a Shopify Order Id + CreateSalesOrder(SalesHeader, OrderId); + + // [WHEN] The sales invoice is posted + LibrarySales.PostSalesDocument(SalesHeader, true, true); + + // [THEN] The sales invoice is posted (document posting is not blocked by the payment failure) + SalesInvoiceHeader.SetRange("Shpfy Order Id", OrderId); + LibraryAssert.IsFalse(SalesInvoiceHeader.IsEmpty(), 'Posted sales invoice should exist'); + + // [THEN] No Cust. Ledger Entry is created for the failed transaction + CustLedgerEntry.SetRange("Shpfy Transaction Id", TransactionId); + LibraryAssert.IsTrue(CustLedgerEntry.IsEmpty(), 'Cust. Ledger Entry should not be created for the failed transaction'); + + // [THEN] No orphaned general journal line is left behind + LibraryAssert.IsTrue(NoJournalLineExistsForTransaction(TransactionId), 'No general journal line should be left behind for the failed transaction'); + + // [THEN] The failure is logged as a skipped record + OrderTransaction.Get(TransactionId); + SkippedRecord.SetRange("Record ID", OrderTransaction.RecordId); + LibraryAssert.IsFalse(SkippedRecord.IsEmpty(), 'A skipped record should be logged for the failed transaction'); + end; + + [Test] + procedure UnitTestPreviewSalesOrderDoesNotAutoPost() + var + SalesHeader: Record "Sales Header"; + SalesInvoiceHeader: Record "Sales Invoice Header"; + GLPostingPreview: TestPage "G/L Posting Preview"; + OrderId: BigInteger; + TransactionId: BigInteger; + begin + // [SCENARIO] Previewing the posting of an invoice does not automatically post the transaction and does not break the preview + + // [GIVEN] Initialized test environment + Initialize(); + + // [GIVEN] A Shopify order with a transaction linked to an auto-post mapping + OrderId := LibraryRandom.RandIntInRange(8000000, 8999999); + TransactionId := LibraryRandom.RandIntInRange(8000000, 8999999); + CreateShopifyOrder(OrderId); + CreateOrderTransaction(TransactionId, OrderId, 0, PaymentMethodMapping.Gateway, Enum::"Shpfy Transaction Type"::Sale, Item."Unit Price"); + EnablePaymentMethodMappingAutoPost(true); + + // [GIVEN] A sales invoice with a Shopify Order Id + CreateSalesOrder(SalesHeader, OrderId); + Commit(); + + // [WHEN] The posting of the sales invoice is previewed + GLPostingPreview.Trap(); + asserterror LibrarySales.PreviewPostSalesDocument(SalesHeader); + + // [THEN] The preview completes without a real error (auto-posting did not run and did not break the preview) + LibraryAssert.AreEqual('', GetLastErrorText(), 'Posting preview should not raise a real error'); + GLPostingPreview.Close(); + + // [THEN] Nothing was actually posted + SalesInvoiceHeader.SetRange("Shpfy Order Id", OrderId); + LibraryAssert.IsTrue(SalesInvoiceHeader.IsEmpty(), 'Preview should not post the sales invoice'); + end; + + [Test] + procedure UnitTestAutoPostDoesNotPostUnrelatedBatchLines() + var + SalesHeader: Record "Sales Header"; + CustLedgerEntry: Record "Cust. Ledger Entry"; + UnrelatedDocNo: Code[20]; + OrderId: BigInteger; + TransactionId: BigInteger; + begin + // [SCENARIO] An unrelated line parked in the configured auto-post batch is not posted when a Shopify invoice auto-posts + + // [GIVEN] Initialized test environment + Initialize(); + + // [GIVEN] An unrelated manual journal line sitting in the configured auto-post batch + UnrelatedDocNo := CreateUnrelatedJournalLine(); + + // [GIVEN] A Shopify order with an auto-post-enabled transaction + OrderId := LibraryRandom.RandIntInRange(9000000, 9499999); + TransactionId := LibraryRandom.RandIntInRange(9000000, 9499999); + CreateShopifyOrder(OrderId); + CreateOrderTransaction(TransactionId, OrderId, 0, PaymentMethodMapping.Gateway, Enum::"Shpfy Transaction Type"::Sale, Item."Unit Price"); + EnablePaymentMethodMappingAutoPost(true); + + // [GIVEN] A sales invoice with a Shopify Order Id + CreateSalesOrder(SalesHeader, OrderId); + + // [WHEN] The sales invoice is posted + LibrarySales.PostSalesDocument(SalesHeader, true, true); + + // [THEN] The Shopify transaction is auto-posted + CustLedgerEntry.SetRange("Shpfy Transaction Id", TransactionId); + LibraryAssert.IsFalse(CustLedgerEntry.IsEmpty(), 'Cust. Ledger Entry should be created for the auto-posted transaction'); + + // [THEN] The unrelated journal line is untouched (still present, not posted) + LibraryAssert.IsTrue(UnrelatedJournalLineExists(UnrelatedDocNo), 'The unrelated journal line in the configured batch must not be posted'); + end; + + [Test] + procedure UnitTestAutoPostAfterDocumentLinkCreation() + var + SalesHeader: Record "Sales Header"; + CustLedgerEntry: Record "Cust. Ledger Entry"; + OrderId: BigInteger; + TransactionId: BigInteger; + begin + // [SCENARIO] Document-link writes are committed before the isolated automatic-posting operation runs + Initialize(); + + OrderId := LibraryRandom.RandIntInRange(14000000, 14999999); + TransactionId := LibraryRandom.RandIntInRange(14000000, 14999999); + CreateShopifyOrder(OrderId); + CreateOrderTransaction(TransactionId, OrderId, 0, PaymentMethodMapping.Gateway, Enum::"Shpfy Transaction Type"::Sale, Item."Unit Price"); + EnablePaymentMethodMappingAutoPost(true); + CreateSalesOrderDocument(SalesHeader, OrderId); + CreateShopifyOrderDocumentLink(SalesHeader, OrderId); + + LibrarySales.PostSalesDocument(SalesHeader, true, true); + + CustLedgerEntry.SetRange("Shpfy Transaction Id", TransactionId); + LibraryAssert.IsFalse(CustLedgerEntry.IsEmpty(), 'Document-link creation must not prevent the transaction from being auto-posted'); + end; + + [Test] + procedure UnitTestAutoPostDefersWhilePartialInvoiceOpen() + var + SalesHeaderToPost: Record "Sales Header"; + SalesHeaderOpen: Record "Sales Header"; + CustLedgerEntry: Record "Cust. Ledger Entry"; + OrderId: BigInteger; + TransactionId: BigInteger; + begin + // [SCENARIO] The order transaction is not consumed while another, not-yet-posted invoice exists for the same order + + // [GIVEN] Initialized test environment + Initialize(); + + // [GIVEN] A Shopify order with an auto-post-enabled transaction that covers both invoices + OrderId := LibraryRandom.RandIntInRange(9500000, 9999999); + TransactionId := LibraryRandom.RandIntInRange(9500000, 9999999); + CreateShopifyOrder(OrderId); + CreateOrderTransaction(TransactionId, OrderId, 0, PaymentMethodMapping.Gateway, Enum::"Shpfy Transaction Type"::Sale, 2 * Item."Unit Price"); + EnablePaymentMethodMappingAutoPost(true); + + // [GIVEN] Two sales invoices for the same Shopify order + CreateSalesOrder(SalesHeaderOpen, OrderId); + CreateSalesOrder(SalesHeaderToPost, OrderId); + + // [WHEN] The first invoice is posted while the second is still open + LibrarySales.PostSalesDocument(SalesHeaderToPost, true, true); + + // [THEN] Auto-posting is deferred - the transaction is not consumed yet + CustLedgerEntry.SetRange("Shpfy Transaction Id", TransactionId); + LibraryAssert.IsTrue(CustLedgerEntry.IsEmpty(), 'Transaction should not be auto-posted while another invoice for the order is still open'); + + // [WHEN] The remaining invoice is posted + LibrarySales.PostSalesDocument(SalesHeaderOpen, true, true); + + // [THEN] The transaction is now auto-posted + CustLedgerEntry.SetRange("Shpfy Transaction Id", TransactionId); + LibraryAssert.IsFalse(CustLedgerEntry.IsEmpty(), 'Transaction should be auto-posted once no open invoice remains for the order.'); + end; + + [Test] + procedure UnitTestSetJournalParametersPropagatesToGeneratedLine() + var + GenJournalLine: Record "Gen. Journal Line"; + OrderTransaction: Record "Shpfy Order Transaction"; + SalesHeader: Record "Sales Header"; + SuggestPayments: Report "Shpfy Suggest Payments"; + PostedInvoiceNo: Code[20]; + OrderId: BigInteger; + TransactionId: BigInteger; + begin + // [SCENARIO] SetJournalParameters carries the mapped template, batch, and posting date onto the generated journal line + + // [GIVEN] Initialized test environment with a posted Shopify invoice and transaction + Initialize(); + // Auto-post must stay off here so the transaction is left for the manual suggest-payments call below. + EnablePaymentMethodMappingAutoPost(false); + OrderId := LibraryRandom.RandIntInRange(10000000, 10999999); + TransactionId := LibraryRandom.RandIntInRange(10000000, 10999999); + CreateShopifyOrder(OrderId); + CreateOrderTransaction(TransactionId, OrderId, 0, PaymentMethodMapping.Gateway, Enum::"Shpfy Transaction Type"::Sale, Item."Unit Price"); + CreateSalesOrder(SalesHeader, OrderId); + PostedInvoiceNo := LibrarySales.PostSalesDocument(SalesHeader, true, true); + + // [WHEN] The suggest-payments report generates lines with explicit journal parameters + OrderTransaction.Get(TransactionId); + SuggestPayments.SetJournalParameters(PaymentMethodMapping."Auto-Post Jnl. Template", PaymentMethodMapping."Auto-Post Jnl. Batch", WorkDate()); + SuggestPayments.GetOrderTransactions(OrderTransaction); + SuggestPayments.CreateGeneralJournalLines(); + + // [THEN] The generated line uses the mapped template, batch, and posting date + GenJournalLine.SetRange("Shpfy Transaction Id", TransactionId); + LibraryAssert.IsTrue(GenJournalLine.FindFirst(), 'A general journal line should be generated for the transaction'); + LibraryAssert.AreEqual(PaymentMethodMapping."Auto-Post Jnl. Template", GenJournalLine."Journal Template Name", 'Journal template should match the mapping'); + LibraryAssert.AreEqual(PaymentMethodMapping."Auto-Post Jnl. Batch", GenJournalLine."Journal Batch Name", 'Journal batch should match the mapping'); + LibraryAssert.AreEqual(WorkDate(), GenJournalLine."Posting Date", 'Posting date should match the value passed to SetJournalParameters'); + LibraryAssert.AreEqual(PostedInvoiceNo, GenJournalLine."Applies-to Doc. No.", 'Generated payment line should apply to the posted invoice'); + + // Clean up the unposted lines so they don't leak into other tests. + GenJournalLine.SetRange("Shpfy Transaction Id", TransactionId); + GenJournalLine.DeleteAll(true); + end; + + [Test] + procedure UnitTestAutoPostWithFuturePostingDateIsHeadless() + var + SalesHeader: Record "Sales Header"; + CustLedgerEntry: Record "Cust. Ledger Entry"; + PostingDate: Date; + OrderId: BigInteger; + TransactionId: BigInteger; + begin + // [SCENARIO] Automatic posting pre-confirms the journal posting-date prompt + Initialize(); + + PostingDate := CalcDate('<1D>', WorkDate()); + OrderId := LibraryRandom.RandIntInRange(15000000, 15999999); + TransactionId := LibraryRandom.RandIntInRange(15000000, 15999999); + CreateShopifyOrder(OrderId); + CreateOrderTransaction(TransactionId, OrderId, 0, PaymentMethodMapping.Gateway, Enum::"Shpfy Transaction Type"::Sale, Item."Unit Price"); + EnablePaymentMethodMappingAutoPost(true); + CreateSalesOrder(SalesHeader, OrderId); + SalesHeader.Validate("Posting Date", PostingDate); + SalesHeader.Modify(true); + EnablePostingAfterWorkingDateConfirmation(); + + LibrarySales.PostSalesDocument(SalesHeader, true, true); + + CustLedgerEntry.SetRange("Shpfy Transaction Id", TransactionId); + LibraryAssert.IsFalse(CustLedgerEntry.IsEmpty(), 'The transaction should be auto-posted without a confirmation dialog'); + DisablePostingAfterWorkingDateConfirmation(); + end; + + [Test] + procedure UnitTestAutoPostSkippedWhenCommitSuppressed() + var + SalesHeader: Record "Sales Header"; + CustLedgerEntry: Record "Cust. Ledger Entry"; + SkippedRecord: Record "Shpfy Skipped Record"; + SalesPost: Codeunit "Sales-Post"; + OrderId: BigInteger; + TransactionId: BigInteger; + begin + // [SCENARIO] Auto-posting is skipped when the caller suppresses commit and owns the transaction + + // [GIVEN] Initialized test environment + Initialize(); + + // [GIVEN] A Shopify order with an auto-post-enabled transaction and a matching sales invoice + OrderId := LibraryRandom.RandIntInRange(11000000, 11999999); + TransactionId := LibraryRandom.RandIntInRange(11000000, 11999999); + CreateShopifyOrder(OrderId); + CreateOrderTransaction(TransactionId, OrderId, 0, PaymentMethodMapping.Gateway, Enum::"Shpfy Transaction Type"::Sale, Item."Unit Price"); + EnablePaymentMethodMappingAutoPost(true); + CreateSalesOrder(SalesHeader, OrderId); + + // [WHEN] The sales invoice is posted with commit suppressed (the caller owns the transaction) + SalesHeader.Ship := true; + SalesHeader.Invoice := true; + SalesHeader.Modify(); + SalesPost.SetSuppressCommit(true); + SalesPost.Run(SalesHeader); + + // [THEN] Auto-posting did not run: no ledger entry and no skipped record for the transaction + CustLedgerEntry.SetRange("Shpfy Transaction Id", TransactionId); + LibraryAssert.IsTrue(CustLedgerEntry.IsEmpty(), 'Auto-posting must not run when commit is suppressed'); + SkippedRecord.SetRange("Shopify Id", TransactionId); + LibraryAssert.IsTrue(SkippedRecord.IsEmpty(), 'No skipped record should be logged when auto-posting is skipped'); + end; + + [Test] + procedure UnitTestAutoPostDefersWhilePartialCreditMemoOpen() + var + SalesHeaderToPost: Record "Sales Header"; + SalesHeaderOpen: Record "Sales Header"; + CustLedgerEntry: Record "Cust. Ledger Entry"; + OrderId: BigInteger; + RefundId: BigInteger; + TransactionId: BigInteger; + begin + // [SCENARIO] The refund transaction is not consumed while another, not-yet-posted credit memo exists for the same refund + + // [GIVEN] Initialized test environment + Initialize(); + + // [GIVEN] A Shopify refund with an auto-post-enabled transaction that covers both credit memos + OrderId := LibraryRandom.RandIntInRange(12000000, 12999999); + RefundId := LibraryRandom.RandIntInRange(12000000, 12999999); + TransactionId := LibraryRandom.RandIntInRange(12000000, 12999999); + CreateShopifyOrder(OrderId); + CreateRefund(RefundId, OrderId); + CreateOrderTransaction(TransactionId, OrderId, RefundId, PaymentMethodMapping.Gateway, Enum::"Shpfy Transaction Type"::Refund, 2 * Item."Unit Price"); + EnablePaymentMethodMappingAutoPost(true); + + // [GIVEN] Two credit memos for the same Shopify refund + CreateCreditMemo(SalesHeaderOpen, RefundId); + CreateCreditMemo(SalesHeaderToPost, RefundId); + + // [WHEN] The first credit memo is posted while the second is still open + LibrarySales.PostSalesDocument(SalesHeaderToPost, true, true); + + // [THEN] Auto-posting is deferred - the transaction is not consumed yet + CustLedgerEntry.SetRange("Shpfy Transaction Id", TransactionId); + LibraryAssert.IsTrue(CustLedgerEntry.IsEmpty(), 'Refund transaction should not be auto-posted while another credit memo for the refund is still open'); + + // [WHEN] The remaining credit memo is posted + LibrarySales.PostSalesDocument(SalesHeaderOpen, true, true); + + // [THEN] The transaction is now auto-posted + CustLedgerEntry.SetRange("Shpfy Transaction Id", TransactionId); + LibraryAssert.IsFalse(CustLedgerEntry.IsEmpty(), 'Refund transaction should be auto-posted once no open credit memo remains for the refund.'); + end; + + [Test] + procedure UnitTestPostableEligibilityExcludesOpenSalesDocument() + var + SalesHeaderOpen: Record "Sales Header"; + SalesHeaderToPost: Record "Sales Header"; + OrderTransaction: Record "Shpfy Order Transaction"; + PaymentMethodMappingForEligibility: Record "Shpfy Payment Method Mapping"; + AutoPostEligibility: Codeunit "Shpfy Auto Post Eligibility"; + OrderId: BigInteger; + TransactionId: BigInteger; + begin + // [SCENARIO] The postable-transactions predicate matches automatic posting's partial-invoice deferral + Initialize(); + + OrderId := LibraryRandom.RandIntInRange(16000000, 16999999); + TransactionId := LibraryRandom.RandIntInRange(16000000, 16999999); + CreateShopifyOrder(OrderId); + CreateOrderTransaction(TransactionId, OrderId, 0, PaymentMethodMapping.Gateway, Enum::"Shpfy Transaction Type"::Sale, 2 * Item."Unit Price"); + EnablePaymentMethodMappingAutoPost(false); + CreateSalesOrder(SalesHeaderOpen, OrderId); + CreateSalesOrder(SalesHeaderToPost, OrderId); + LibrarySales.PostSalesDocument(SalesHeaderToPost, true, true); + EnablePaymentMethodMappingAutoPost(true); + OrderTransaction.Get(TransactionId); + + LibraryAssert.IsFalse( + AutoPostEligibility.IsReadyToPost(OrderTransaction, PaymentMethodMappingForEligibility), + 'A transaction must not be shown as postable while another sales document for its order is open'); + + SalesHeaderOpen.Delete(true); + LibraryAssert.IsTrue( + AutoPostEligibility.IsReadyToPost(OrderTransaction, PaymentMethodMappingForEligibility), + 'A transaction should be postable after its invoice is posted and no related sales document remains open'); + end; + + local procedure Initialize() + var + LibraryERMCountryData: Codeunit "Library - ERM Country Data"; + CommunicationMgt: Codeunit "Shpfy Communication Mgt."; + begin + if IsInitialized then + exit; + + Codeunit.Run(Codeunit::"Shpfy Initialize Test"); + + LibraryERMCountryData.CreateVATData(); + LibraryERMCountryData.UpdateGeneralPostingSetup(); + CreateItem(); + LibrarySales.CreateCustomer(Customer); + + Shop := CommunicationMgt.GetShopRecord(); + Shop."Logging Mode" := Shop."Logging Mode"::"Error Only"; + Shop.Modify(); + + CreatePaymentMethodMapping(); + + IsInitialized := true; + end; + + local procedure CreateItem() + var + LibraryInventory: Codeunit "Library - Inventory"; + Amount: Decimal; + begin + Amount := LibraryRandom.RandIntInRange(10000, 99999); + // A service item is used so posting the sales invoice does not require Inventory Posting Setup; + // the feature only depends on the resulting customer ledger entry, not on inventory posting. + LibraryInventory.CreateItem(Item); + Item.Validate(Type, Item.Type::Service); + Item.Validate("Unit Price", Amount); + Item.Modify(true); + end; + + local procedure CreateShopifyOrder(OrderId: BigInteger) + var + ShpfyOrderHeader: Record "Shpfy Order Header"; + begin + ShpfyOrderHeader.Init(); + ShpfyOrderHeader."Shopify Order Id" := OrderId; + ShpfyOrderHeader.Processed := true; + ShpfyOrderHeader.Insert(); + end; + + local procedure CreateOrderTransaction(TransactionId: BigInteger; OrderId: BigInteger; RefundId: BigInteger; Gateway: Text[30]; TransactionType: Enum "Shpfy Transaction Type"; Amount: Decimal) + var + OrderTransaction: Record "Shpfy Order Transaction"; + begin + OrderTransaction.Init(); + OrderTransaction."Shopify Transaction Id" := TransactionId; + OrderTransaction."Shopify Order Id" := OrderId; + OrderTransaction."Refund Id" := RefundId; + OrderTransaction.Shop := Shop.Code; + OrderTransaction.Gateway := Gateway; + OrderTransaction.Type := TransactionType; + OrderTransaction.Status := OrderTransaction.Status::Success; + OrderTransaction.Amount := Amount; + OrderTransaction.Insert(); + end; + + local procedure CreateSalesOrder(var SalesHeader: Record "Sales Header"; OrderId: BigInteger) + var + SalesLine: Record "Sales Line"; + begin + LibrarySales.CreateSalesHeader(SalesHeader, SalesHeader."Document Type"::Invoice, Customer."No."); + SalesHeader."Shpfy Order Id" := OrderId; + SalesHeader.Modify(); + LibrarySales.CreateSalesLine(SalesLine, SalesHeader, SalesLine.Type::Item, Item."No.", 1); + end; + + local procedure CreateSalesOrderDocument(var SalesHeader: Record "Sales Header"; OrderId: BigInteger) + var + SalesLine: Record "Sales Line"; + begin + LibrarySales.CreateSalesHeader(SalesHeader, SalesHeader."Document Type"::Order, Customer."No."); + SalesHeader."Shpfy Order Id" := OrderId; + SalesHeader.Modify(); + LibrarySales.CreateSalesLine(SalesLine, SalesHeader, SalesLine.Type::Item, Item."No.", 1); + end; + + local procedure CreateShopifyOrderDocumentLink(SalesHeader: Record "Sales Header"; OrderId: BigInteger) + var + DocLinkToBCDoc: Record "Shpfy Doc. Link To Doc."; + begin + DocLinkToBCDoc."Shopify Document Type" := DocLinkToBCDoc."Shopify Document Type"::"Shopify Shop Order"; + DocLinkToBCDoc."Shopify Document Id" := OrderId; + DocLinkToBCDoc."Document Type" := DocLinkToBCDoc."Document Type"::"Sales Order"; + DocLinkToBCDoc."Document No." := SalesHeader."No."; + DocLinkToBCDoc.Insert(); + end; + + local procedure CreateCreditMemo(var SalesHeader: Record "Sales Header"; RefundId: BigInteger) + var + SalesLine: Record "Sales Line"; + begin + LibrarySales.CreateSalesHeader(SalesHeader, SalesHeader."Document Type"::"Credit Memo", Customer."No."); + SalesHeader."Shpfy Refund Id" := RefundId; + SalesHeader.Modify(); + LibrarySales.CreateSalesLine(SalesLine, SalesHeader, SalesLine.Type::Item, Item."No.", 1); + end; + + local procedure CreateRefund(RefundId: BigInteger; OrderId: BigInteger) + var + RefundHeader: Record "Shpfy Refund Header"; + begin + RefundHeader.Init(); + RefundHeader."Refund Id" := RefundId; + RefundHeader."Order Id" := OrderId; + RefundHeader.Insert(); + end; + + local procedure EnablePaymentMethodMappingAutoPost(AutoPost: Boolean) + begin + PaymentMethodMapping."Post Automatically" := AutoPost; + PaymentMethodMapping.Modify(); + end; + + local procedure CreatePaymentMethodMapping() + var + GenJournalBatch: Record "Gen. Journal Batch"; + begin + PaymentMethodMapping.Init(); + PaymentMethodMapping."Shop Code" := Shop.Code; + PaymentMethodMapping.Gateway := CopyStr(LibraryRandom.RandText(30), 1, MaxStrLen(PaymentMethodMapping.Gateway)); + PaymentMethodMapping."Post Automatically" := true; + CreateJournalBatch(GenJournalBatch); + PaymentMethodMapping."Auto-Post Jnl. Template" := GenJournalBatch."Journal Template Name"; + PaymentMethodMapping."Auto-Post Jnl. Batch" := GenJournalBatch.Name; + PaymentMethodMapping.Insert(); + end; + + local procedure CreateFailingPaymentMethodMapping(): Text[30] + var + FailingMapping: Record "Shpfy Payment Method Mapping"; + GenJournalBatch: Record "Gen. Journal Batch"; + FailingGateway: Text[30]; + begin + // A batch with a balancing account but without a number series: journal lines get no document number + // and posting therefore fails, which is used to exercise the best-effort failure handling. + CreateJournalBatch(GenJournalBatch); + GenJournalBatch.Validate("No. Series", ''); + GenJournalBatch.Modify(true); + + FailingGateway := CopyStr(LibraryRandom.RandText(30), 1, MaxStrLen(FailingMapping.Gateway)); + FailingMapping."Shop Code" := Shop.Code; + FailingMapping.Gateway := FailingGateway; + FailingMapping."Post Automatically" := true; + FailingMapping."Auto-Post Jnl. Template" := GenJournalBatch."Journal Template Name"; + FailingMapping."Auto-Post Jnl. Batch" := GenJournalBatch.Name; + FailingMapping.Insert(); + exit(FailingGateway); + end; + + local procedure CreateJournalBatch(var GenJournalBatch: Record "Gen. Journal Batch") + var + GenJournalTemplate: Record "Gen. Journal Template"; + SourceCode: Record "Source Code"; + begin + LibraryERM.CreateSourceCode(SourceCode); + LibraryERM.CreateGenJournalTemplate(GenJournalTemplate); + GenJournalTemplate.Validate(Type, GenJournalTemplate.Type::"Cash Receipts"); + GenJournalTemplate.Validate("Source Code", SourceCode.Code); + GenJournalTemplate.Modify(true); + LibraryERM.CreateGenJournalBatch(GenJournalBatch, GenJournalTemplate.Name); + GenJournalBatch.Validate("Bal. Account Type", GenJournalBatch."Bal. Account Type"::"G/L Account"); + GenJournalBatch.Validate("Bal. Account No.", CreateGLAccount()); + GenJournalBatch.Validate("No. Series", LibraryERM.CreateNoSeriesCode()); + GenJournalBatch.Modify(true); + end; + + local procedure CreateGLAccount(): Code[20] + var + GLAccount: Record "G/L Account"; + begin + LibraryERM.CreateGLAccount(GLAccount); + GLAccount.Validate("Direct Posting", true); + GLAccount.Modify(true); + exit(GLAccount."No."); + end; + + local procedure EnablePostingAfterWorkingDateConfirmation() + var + AccountingPeriod: Record "Accounting Period"; + MyNotifications: Record "My Notifications"; + InstructionMgt: Codeunit "Instruction Mgt."; + begin + if AccountingPeriod.IsEmpty() then begin + AccountingPeriod."Starting Date" := WorkDate(); + AccountingPeriod.Insert(); + end; + MyNotifications.InsertDefault( + InstructionMgt.GetPostingAfterWorkingDateNotificationId(), + InstructionMgt.PostingAfterWorkingDateNotAllowedCode(), + '', true); + end; + + local procedure DisablePostingAfterWorkingDateConfirmation() + var + MyNotifications: Record "My Notifications"; + InstructionMgt: Codeunit "Instruction Mgt."; + begin + if MyNotifications.Get(UserId(), InstructionMgt.GetPostingAfterWorkingDateNotificationId()) then + MyNotifications.Delete(); + end; + + local procedure NoJournalLineExistsForTransaction(TransactionId: BigInteger): Boolean + var + GenJournalLine: Record "Gen. Journal Line"; + begin + GenJournalLine.SetRange("Shpfy Transaction Id", TransactionId); + exit(GenJournalLine.IsEmpty()); + end; + + local procedure CreateUnrelatedJournalLine(): Code[20] + var + GenJournalLine: Record "Gen. Journal Line"; + GenJournalBatch: Record "Gen. Journal Batch"; + NoSeries: Codeunit "No. Series"; + DocNo: Code[20]; + LastLineNo: Integer; + begin + // A self-balancing line parked in the configured batch; it would post if the whole batch posted. + GenJournalBatch.Get(PaymentMethodMapping."Auto-Post Jnl. Template", PaymentMethodMapping."Auto-Post Jnl. Batch"); + DocNo := NoSeries.PeekNextNo(GenJournalBatch."No. Series", WorkDate()); + + GenJournalLine.SetRange("Journal Template Name", GenJournalBatch."Journal Template Name"); + GenJournalLine.SetRange("Journal Batch Name", GenJournalBatch.Name); + if GenJournalLine.FindLast() then + LastLineNo := GenJournalLine."Line No."; + + GenJournalLine.Init(); + GenJournalLine."Journal Template Name" := GenJournalBatch."Journal Template Name"; + GenJournalLine."Journal Batch Name" := GenJournalBatch.Name; + GenJournalLine."Line No." := LastLineNo + 10000; + GenJournalLine.Validate("Posting Date", WorkDate()); + GenJournalLine."Document No." := DocNo; + GenJournalLine.Validate("Account Type", GenJournalLine."Account Type"::"G/L Account"); + GenJournalLine.Validate("Account No.", CreateGLAccount()); + GenJournalLine.Validate(Amount, LibraryRandom.RandDecInRange(100, 1000, 2)); + GenJournalLine.Validate("Bal. Account Type", GenJournalLine."Bal. Account Type"::"G/L Account"); + GenJournalLine.Validate("Bal. Account No.", CreateGLAccount()); + GenJournalLine.Insert(true); + exit(DocNo); + end; + + local procedure UnrelatedJournalLineExists(DocNo: Code[20]): Boolean + var + GenJournalLine: Record "Gen. Journal Line"; + begin + GenJournalLine.SetRange("Journal Template Name", PaymentMethodMapping."Auto-Post Jnl. Template"); + GenJournalLine.SetRange("Journal Batch Name", PaymentMethodMapping."Auto-Post Jnl. Batch"); + GenJournalLine.SetRange("Document No.", DocNo); + exit(not GenJournalLine.IsEmpty()); + end; +} diff --git a/src/Apps/W1/Shopify/Test/app.json b/src/Apps/W1/Shopify/Test/app.json index 16b2c8826a4..7c3caa0dc67 100644 --- a/src/Apps/W1/Shopify/Test/app.json +++ b/src/Apps/W1/Shopify/Test/app.json @@ -52,6 +52,10 @@ "screenshots": [], "platform": "29.0.0.0", "idRanges": [ + { + "from": 139415, + "to": 139420 + }, { "from": 134241, "to": 134247