[Shopify] Fix incorrect G/L line on credit memo for refund with exchange item#8554
[Shopify] Fix incorrect G/L line on credit memo for refund with exchange item#8554onbuyuka wants to merge 2 commits into
Conversation
…nge item When a Shopify return includes an exchange item (customer returns A, keeps B), the Sales Credit Memo created from the refund contained an incorrect G/L line posted to Shop.Refund Account, which doubled the customer balance impact of the exchange-item value. Symptom from the bug report: invoice 2,258, CR memo 1,528, customer ledger residual 730 (== 2 x 365 exchange value). Root cause - Shopify exposes exchange items on Return.exchangeLineItems, not on Refund.refundLineItems. Refund.totalRefundedSet is already net of the exchange value. So when the connector summed refund lines it found a gap vs totalRefundedSet and FillRemainingAmountLineFields inserted a balancing G/L line for that gap. Fix - Phase A (order): pull Order.returns.exchangeLineItems via a new paginated GraphQL query and flag the matching Shpfy Order Line rows with `Is Exchange Item'' = true. ShpfyProcessOrder skips flagged rows when projecting to BC Sales Lines, so the invoice does not include item B. - Phase B (refund): pull Refund.return.exchangeLineItems via a new paginated GraphQL query and insert one synthetic Shpfy Refund Line per (ExchangeLineItem x lineItem) pair with negative quantity, Restock Type = Return, and prices mirroring the new item. The existing CreateSalesLinesFromRefundLines path then emits a negative-qty Type::Item CR-memo line that offsets the exchange value; totals reconcile to Refund.totalRefundedSet and no spurious G/L line is created. Net effect for the reported scenario - Invoice = 1,893 (A only). - CR memo = +1 A and -1 B totalling 1,528. - Customer ledger residual = 365 (correct). - B leaves BC inventory exactly once via the CR memo negative line, matching what Shopify physically shipped. Notes - The Phase B fetch runs as part of GetRefund, which is called from ImportOrder.SetAndCreateRelatedRecords before InsertOrderLinesAndRelatedRecords. At that point the exchange item's Shpfy Order Line does not exist yet, so the new code stores the OrderLineId directly and lets the FlowField resolve later when the credit memo is built (same pattern as the existing FillInRefundLine). - Only takes effect for orders / refunds (re)imported after deployment; refunds already in DB need re-import to pick up the synthetic exchange refund lines. AB#637828 Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Replace SalesLine.FindFirst() with not SalesLine.IsEmpty() in the two quantity-sign assertions. The returned record is not used, which AA0175 flags as an error in the BCApps ruleset (CodeCop, generalAction = Error). AB#637828 Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
| OrderHeader.Modify(); | ||
| end; | ||
|
|
||
| internal procedure MarkExchangeItemOrderLines(var OrderHeader: Record "Shpfy Order Header") |
There was a problem hiding this comment.
Extra GraphQL call on every order import
MarkExchangeItemOrderLines is called unconditionally for every order import, making an extra paginated GraphQL API call even for orders that have no returns or exchanges. This will increase API quota consumption and slow down bulk order imports.
Recommendation:
- Add an early-exit guard before making the first API call. For example, check whether any returns exist for the order (e.g. via a cheap metadata field or a header flag) before entering the repeat..until loop.
| internal procedure MarkExchangeItemOrderLines(var OrderHeader: Record "Shpfy Order Header") | |
| // Before Parameters.Add('OrderId', ...) in MarkExchangeItemOrderLines: | |
| if not OrderHeader."Has Returns" then | |
| exit; |
👍 useful · ❤️ especially valuable · 👎 wrong - reply with why
| JResponse := CommunicationMgt.ExecuteGraphQL(GraphQLType, Parameters); | ||
| GraphQLType := "Shpfy GraphQL Type"::Orders_GetNextOrderExchangeLineItems; | ||
| JReturns := JsonHelper.GetJsonArray(JResponse, 'data.order.returns.nodes'); | ||
| if Parameters.ContainsKey('After') then |
There was a problem hiding this comment.
Pagination cursor set before first page check
In MarkExchangeItemOrderLines the 'After' cursor is unconditionally added from the first API response before checking hasNextPage. If endCursor is null/empty and hasNextPage is false, the empty cursor value persists in Parameters for no reason. More critically, if hasNextPage is unexpectedly true with a null cursor, the loop will make repeated identical API calls.
Recommendation:
- Move the cursor update inside the hasNextPage check, mirroring the pattern used in other pagination loops in this codebase.
| if Parameters.ContainsKey('After') then | |
| // After processing JReturns, only set the cursor if there are more pages: | |
| if JsonHelper.GetValueAsBoolean(JResponse, 'data.order.returns.pageInfo.hasNextPage') then begin | |
| if Parameters.ContainsKey('After') then | |
| Parameters.Set('After', JsonHelper.GetValueAsText(JResponse, 'data.order.returns.pageInfo.endCursor')) | |
| else | |
| Parameters.Add('After', JsonHelper.GetValueAsText(JResponse, 'data.order.returns.pageInfo.endCursor')); | |
| end; |
👍 useful · ❤️ especially valuable · 👎 wrong - reply with why
| until not JsonHelper.GetValueAsBoolean(JResponse, 'data.order.returns.pageInfo.hasNextPage'); | ||
|
|
||
| if ExchangeLineIds.Count() = 0 then | ||
| exit; |
There was a problem hiding this comment.
Missing lock on rows updated in FindSet loop
MarkExchangeItemOrderLines calls OrderLine.FindSet() (no lock) and then calls OrderLine.Modify() inside the loop. Concurrent processes that also modify OrderLine records could cause lost updates or non-repeatable reads.
Recommendation:
- Use OrderLine.FindSet(true) to acquire an optimistic lock, or call OrderLine.LockTable() before FindSet to prevent concurrent modification.
| exit; | |
| OrderLine.LockTable(); | |
| if OrderLine.FindSet() then | |
| repeat | |
| if ExchangeLineIds.Contains(OrderLine."Line Id") and (not OrderLine."Is Exchange Item") then begin | |
| OrderLine."Is Exchange Item" := true; | |
| OrderLine.Modify(); | |
| end; | |
| until OrderLine.Next() = 0; |
👍 useful · ❤️ especially valuable · 👎 wrong - reply with why
| begin | ||
| Parameters.Add('RefundId', Format(RefundId)); | ||
| GraphQLType := "Shpfy GraphQL Type"::Refunds_GetRefundExchangeLines; | ||
| repeat |
There was a problem hiding this comment.
Pagination cursor set before first page check
In GetRefundExchangeLines the 'After' cursor is added/set from the response before confirming the first page returned any items. If the API returns an empty nodes array and a null endCursor, Parameters will contain an empty 'After' value that is sent in subsequent calls, which may cause API errors or an infinite loop if hasNextPage is incorrectly true.
Recommendation:
- Only update the 'After' cursor when hasNextPage is true, or guard against an empty endCursor value before setting the parameter.
| repeat | |
| if JsonHelper.GetValueAsBoolean(JResponse, 'data.refund.return.exchangeLineItems.pageInfo.hasNextPage') then begin | |
| if Parameters.ContainsKey('After') then | |
| Parameters.Set('After', JsonHelper.GetValueAsText(JResponse, 'data.refund.return.exchangeLineItems.pageInfo.endCursor')) | |
| else | |
| Parameters.Add('After', JsonHelper.GetValueAsText(JResponse, 'data.refund.return.exchangeLineItems.pageInfo.endCursor')); | |
| end; |
👍 useful · ❤️ especially valuable · 👎 wrong - reply with why
DataCapture recorded before full line processingIn GetRefundExchangeLines, DataCapture.Add is called immediately after the first ExecuteGraphQL call, before FillInExchangeRefundLine processes the response. If FillInExchangeRefundLine later throws an error, the data capture record already exists, potentially causing duplicate capture entries on retry. Recommendation:
Line mapping was unavailable, so this was posted as an issue comment. 👍 useful · ❤️ especially valuable · 👎 wrong - reply with why |
| JTaxLines := JsonHelper.GetJsonArray(JLineItem, 'taxLines'); | ||
| foreach JTaxLine in JTaxLines do begin | ||
| TotalTaxShop += JsonHelper.GetValueAsDecimal(JTaxLine, 'priceSet.shopMoney.amount'); | ||
| TotalTaxPresentment += JsonHelper.GetValueAsDecimal(JTaxLine, 'priceSet.presentmentMoney.amount'); |
There was a problem hiding this comment.
Insert then Modify pattern risks orphaned rows
FillInExchangeRefundLine calls RefundLine.Insert() and then sets several fields followed by RefundLine.Modify(). If the process is interrupted between Insert and Modify (e.g. by an exception from JsonHelper calls), an incomplete RefundLine row with default/zero field values will be left in the database.
Recommendation:
- Populate all fields before calling Insert, or use a single Modify(true) with all fields set in one block.
| TotalTaxPresentment += JsonHelper.GetValueAsDecimal(JTaxLine, 'priceSet.presentmentMoney.amount'); | |
| RefundLine.Init(); | |
| RefundLine."Refund Line Id" := SyntheticRefundLineId; | |
| RefundLine."Refund Id" := RefundHeader."Refund Id"; | |
| RefundLine."Order Line Id" := OrderLineId; | |
| RefundLine."Restock Type" := RefundLine."Restock Type"::Return; | |
| RefundLine.Quantity := -ExchangeQuantity; | |
| // ... set all other fields ... | |
| if not RefundLine.Get(RefundHeader."Refund Id", SyntheticRefundLineId) then | |
| RefundLine.Insert() | |
| else | |
| RefundLine.Modify(); |
👍 useful · ❤️ especially valuable · 👎 wrong - reply with why
| RefundLine."Presentment Subtotal Amount" := -SubtotalPresentment; | ||
| RefundLine."Total Tax Amount" := -TotalTaxShop; | ||
| RefundLine."Presentment Total Tax Amount" := -TotalTaxPresentment; | ||
| RefundLine."Can Create Credit Memo" := true; |
There was a problem hiding this comment.
Synthetic ID collision risk at 1000+ line items
SyntheticExchangeRefundLineId computes -(ExchangeLineItemId * 1000 + LineItemIndex). The in-code comment acknowledges collision risk beyond 1000 line items per ExchangeLineItem. If Shopify ever returns more than 1000 entries in lineItems, two distinct exchange lines will map to the same synthetic ID, causing a duplicate primary-key error or silent data overwrite.
Recommendation:
- Use a larger multiplier (e.g. 10000) or adopt a hash/UUID-based synthetic ID generation scheme to reduce collision probability. At minimum add a guard that raises an error when LineItemIndex exceeds the safe limit.
| RefundLine."Can Create Credit Memo" := true; | |
| local procedure SyntheticExchangeRefundLineId(ExchangeLineItemId: BigInteger; LineItemIndex: Integer): BigInteger | |
| const | |
| MaxLineItemsPerExchange = 10000; | |
| begin | |
| if LineItemIndex > MaxLineItemsPerExchange then | |
| Error('Exchange line item index %1 exceeds supported limit %2.', LineItemIndex, MaxLineItemsPerExchange); | |
| exit(-((ExchangeLineItemId * MaxLineItemsPerExchange) + LineItemIndex)); | |
| end; |
👍 useful · ❤️ especially valuable · 👎 wrong - reply with why
What & why
A Sales Credit Memo created from a Shopify refund contained an incorrect
G/L Accountline posted toShop."Refund Account"whenever the originating return included an exchange item (customer returns item A and keeps a different item B in exchange). The bug doubled the customer-balance impact of the exchange value -- in the reported scenario: invoice 2,258, CR memo 1,528, customer ledger residual 730 == 2 × 365 (where 365 = exchange-item value).Root cause
Shopify exposes exchange items on
Return.exchangeLineItems, not onRefund.refundLineItems, andRefund.totalRefundedSetis already net of the exchange value. The connector summed refund lines (returned items only) againsttotalRefundedSetandFillRemainingAmountLineFieldsinserted a balancing G/L line for the gap.Fix (two phases)
Phase A -- order import
Orders_GetOrderExchangeLineItems/…_Nextthat readorder(id) { returns { exchangeLineItems { lineItems { id } } } }.Shpfy Order Line."Is Exchange Item"Boolean.ShpfyImportOrder.MarkExchangeItemOrderLinesruns after order lines are imported and flags exchange-item rows.ShpfyProcessOrder.CreateLinesFromShopifyOrderandApplyGlobalDiscountsskip flagged rows so the BC sales invoice excludes the exchange item.ConsiderRefundsInQuantityAndAmountsskips flagged rows so the Phase-B negative-qty refund entries don't perturb order-header math.Phase B -- refund import
Refunds_GetRefundExchangeLines/…_Nextthat readrefund(id) { return { exchangeLineItems { nodes { id quantity lineItems { id originalUnitPriceSet { ... } ... } } } } }.Shpfy Refund Line."Is Exchange Item"Boolean.ShpfyRefundsAPI.GetRefundExchangeLinesinserts one syntheticShpfy Refund Lineper(ExchangeLineItem × lineItem)pair: synthetic negativeRefund Line Id(so it can't collide with Shopify-issued positive ids),Quantity = -ExchangeQuantity,Restock Type = Return, prices taken from the new item.CreateSalesLinesFromRefundLinesthen emits a negative-qtyType::ItemCR-memo line that offsets the exchange value -- totals reconcile toRefund.totalRefundedSetwith no balancing G/L line.Net effect for the reported scenario
Linked work
Fixes #
AB#637828
How I validated this
What I tested and the outcome
al_buildclean for bothShopify Connector(App) andShopify Connector Testprojects.al_run_testsagainstShpfy Order Refund Test(139611): 14/14 pass, including the newUnitTestCreateCrMemoFromRefundWithExchangeItemwhich asserts:Type::Itemsales lines on the credit memo with quantities+1and-1;Type::"G/L Account"line pointing atShop."Refund Account";SalesHeader."Amount Including VAT"equalsRefundHeader."Total Refunded Amount".Risk & compatibility
"Is Exchange Item"Boolean fields onShpfy Order Line(id 22) andShpfy Refund Line(id 14). Defaultfalse; existing rows behave unchanged.read_returns/read_ordersalready covered by the connector's scope.Shpfy Refund Header.Updated Atgates the refund header refresh, but my newGetRefundExchangeLinescall runs after that gate and on every fetch).Shpfy Order Subform,Shpfy Refund Lines).