[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 7 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>
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 |
|
Extra GraphQL call for every imported orderMarkExchangeItemOrderLines unconditionally executes at least one GraphQL API call (Orders_GetOrderExchangeLineItems) for every order import, including orders that have never had a return. Because the repeat…until loop fires before checking hasNextPage, there is no zero-call fast path. For high-volume Shopify stores this substantially increases API quota consumption and import duration. Recommendation:
Line mapping was unavailable, so this was posted as an issue comment. 👍 useful · ❤️ especially valuable · 👎 wrong - reply with why |
Synthetic refund line ID collision above 1000 line itemsSyntheticExchangeRefundLineId computes -(ExchangeLineItemId * 1000 + LineItemIndex). If any ExchangeLineItem has more than 999 associated line items, or if two ExchangeLineItems differ by exactly 1 and the first has LineItemIndex values reaching into the thousands, the formula produces duplicate IDs. The comment acknowledges "before collision risk" but leaves it silent—a collision would silently overwrite the earlier record during RefundLine.Insert/Modify. Recommendation:
Line mapping was unavailable, so this was posted as an issue comment. 👍 useful · ❤️ especially valuable · 👎 wrong - reply with why |
- MarkExchangeItemOrderLines: early-exit when the order has no return
(Return Status is blank or No Return), avoiding an extra GraphQL call on
the common no-return import path.
- MarkExchangeItemOrderLines: SetLoadFields("Line Id", "Is Exchange Item")
before the order-line scan so only the needed fields are fetched.
- Synthetic refund-line id: derive from -OrderLineId (a Shopify line item id
is globally unique) instead of -(ExchangeLineItemId * 1000 + index). This
removes the 1000-line-item collision ceiling and drops the helper plus the
index/multiplier bookkeeping.
- ShpfyRefundLines page: hide the internal "Is Exchange Item" column by
default (Visible = false); users can still reveal it via personalization.
AB#637828
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
|
Thanks for the review. Pushed 17360cb addressing the actionable items; rationale for the rest below. Applied
Not changed (with rationale)
|
|
Copilot PR ReviewIteration 5 · Outcome: completed Knowledge source: https://github.com/microsoft/BCQuality@822cae1b2771ac25f665f73369f69093bd4fd630 Findings by domainFindings split into Knowledge-backed (cite a BCQuality article) and Agent (the agent's own judgement, no matching BCQuality rule).
Totals: 0 knowledge-backed · 1 agent findings. Orchestrator pre-filter (13 file(s) excluded)
Findings produced by the Copilot CLI agent against BCQuality at |
Resolve conflict in ShpfyOrderRefundTest.Codeunit.al by keeping both the exchange-item credit-memo test and the pending-transaction tests. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
…efund-exchange-item # Conflicts: # src/Apps/W1/Shopify/App/src/GraphQL/Enums/ShpfyGraphQLType.Enum.al
- ApplyGlobalDiscounts (ShpfyProcessOrder): revert the SetRange("Is Exchange
Item", false) on the discount CalcSums. OrderHeader."Discount Amount" is
Shopify's order-level total and includes any discount on the exchange line,
so the line-discount sum in the residual formula must include every line
that contributed to it. Excluding the exchange line overstated the residual
by that line's own discount and over-applied invoice discount to the
(exchange-excluded) document. The exchange line is still kept out of the
actual sales document by the separate filter in CreateLinesFromShopifyOrder.
- CreateSalesLinesFromRefundLines (ShpfyCreateSalesDocRefund): resolve the
exchange-item refund line's Location Id from the linked Shpfy Order Line
(field "Location Id") instead of relying on the hardcoded 0. Because the
exchange item's invoice line is excluded, this negative-qty credit-memo line
is the only BC line that decrements inventory for it, so it must be
attributed to the fulfillment location rather than always falling back to
Shop."Return Location". Resolution is done at credit-memo build time (where
the order line reliably exists) because the exchange refund line is created
during order import before order lines are inserted.
AB#637828
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
|
Pull request was closed
| Parameters.Add('After', JsonHelper.GetValueAsText(JResponse, 'data.refund.return.exchangeLineItems.pageInfo.endCursor')); | ||
|
|
||
| foreach JExchangeLineItem in JExchangeLineItems do begin | ||
| ExchangeQuantity := JsonHelper.GetValueAsInteger(JExchangeLineItem, 'quantity'); |
There was a problem hiding this comment.
In ShpfyRefundsAPI.GetRefundExchangeLines, ExchangeQuantity is read once per ExchangeLineItem ('quantity' field) but FillInExchangeRefundLine is invoked once per entry in that same ExchangeLineItem's 'lineItems' array, passing the full ExchangeLineItem-level ExchangeQuantity to every entry.
MarkExchangeItemOrderLines (same PR) explicitly loops over multiple 'lineItems' per ExchangeLineItem to collect distinct order-line ids, which shows the author anticipated an ExchangeLineItem can be fulfilled by more than one order line (e.g. split fulfillment) -- yet FillInExchangeRefundLine has no way to know each split's individual share of the quantity, because the GraphQL query fetches 'quantity' only at the ExchangeLineItem level, not per lineItem. When an ExchangeLineItem has more than one lineItems entry, each resulting synthetic 'Shpfy Refund Line' will carry the FULL ExchangeQuantity (and the derived Subtotal/Tax amounts computed from it) instead of a proportional share, double- (or N-times-) counting the exchange offset on the credit memo. The one new test (UnitTestCreateCrMemoFromRefundWithExchangeItem) only exercises a single lineItem per exchange, so this path is unexercised. If the impact were confirmed to be a common real-world scenario this would warrant a major/blocker-level fix (and a corresponding BCQuality knowledge rule); recommend either fetching a per-lineItem quantity from the Shopify API if available, or asserting/handling the single-lineItem case explicitly and logging/erroring on the multi-lineItem case until it is properly split.
👍 useful · ❤️ especially valuable · 👎 wrong - reply with why
There was a problem hiding this comment.
This one looks make sense, should we consider to improve it?
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.CreateLinesFromShopifyOrderskips flagged rows so the BC sales invoice excludes the exchange item. (ApplyGlobalDiscountsdeliberately keeps summing the discount over all order lines, including the exchange line, becauseOrderHeader."Discount Amount"is Shopify's order-level total and already includes any discount on that line.)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 x 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.CreateSalesLinesFromRefundLinesresolves itsLocation Idfrom the linkedShpfy Order Line(the fulfillment location) at credit-memo build time -- the exchange refund line is created during order import before order lines exist, so the location cannot be resolved earlier.Net effect for the reported scenario
Linked work
Fixes AB#637828
How I validated this
What I tested and the outcome
al_buildclean for theShopify Connector(App) project. (TheShopify Connector Testproject cannot be compiled on this local enlistment due to a known environmental symbol-cache issue; the test suite runs in CI.)al_run_testsagainstShpfy Order Refund Test(139611) in earlier iterations: 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 Order Subform,Shpfy Refund Lines).