Skip to content

[Shopify] Fix incorrect G/L line on credit memo for refund with exchange item#8554

Open
onbuyuka wants to merge 7 commits into
mainfrom
bugs/637828-shopify-refund-exchange-item
Open

[Shopify] Fix incorrect G/L line on credit memo for refund with exchange item#8554
onbuyuka wants to merge 7 commits into
mainfrom
bugs/637828-shopify-refund-exchange-item

Conversation

@onbuyuka

@onbuyuka onbuyuka commented Jun 9, 2026

Copy link
Copy Markdown
Contributor

What & why

A Sales Credit Memo created from a Shopify refund contained an incorrect G/L Account line posted to Shop."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 on Refund.refundLineItems, and Refund.totalRefundedSet is already net of the exchange value. The connector summed refund lines (returned items only) against totalRefundedSet and FillRemainingAmountLineFields inserted a balancing G/L line for the gap.

Fix (two phases)

Phase A -- order import

  • New paginated GraphQL queries Orders_GetOrderExchangeLineItems / ..._Next that read order(id) { returns { exchangeLineItems { lineItems { id } } } }.
  • New Shpfy Order Line."Is Exchange Item" Boolean.
  • ShpfyImportOrder.MarkExchangeItemOrderLines runs after order lines are imported and flags exchange-item rows.
  • ShpfyProcessOrder.CreateLinesFromShopifyOrder skips flagged rows so the BC sales invoice excludes the exchange item. (ApplyGlobalDiscounts deliberately keeps summing the discount over all order lines, including the exchange line, because OrderHeader."Discount Amount" is Shopify's order-level total and already includes any discount on that line.)
  • ConsiderRefundsInQuantityAndAmounts skips flagged rows so the Phase-B negative-qty refund entries don't perturb order-header math.

Phase B -- refund import

  • New paginated GraphQL queries Refunds_GetRefundExchangeLines / ..._Next that read refund(id) { return { exchangeLineItems { nodes { id quantity lineItems { id originalUnitPriceSet { ... } ... } } } } }.
  • New Shpfy Refund Line."Is Exchange Item" Boolean.
  • ShpfyRefundsAPI.GetRefundExchangeLines inserts one synthetic Shpfy Refund Line per (ExchangeLineItem x lineItem) pair: synthetic negative Refund Line Id (so it can't collide with Shopify-issued positive ids), Quantity = -ExchangeQuantity, Restock Type = Return, prices taken from the new item.
  • The existing CreateSalesLinesFromRefundLines then emits a negative-qty Type::Item CR-memo line that offsets the exchange value -- totals reconcile to Refund.totalRefundedSet with no balancing G/L line.
  • Because this negative-qty line is the only BC line that decrements inventory for the exchange item, CreateSalesLinesFromRefundLines resolves its Location Id from the linked Shpfy 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

Before After
Sales invoice 2,258 1,893
CR memo lines 2 (A item + G/L Refund Acc.) 2 (A +1, B -1)
CR memo total 1,528 1,528
Customer ledger residual 730 ❌ 365 ✓

Linked work

Fixes AB#637828

How I validated this

  • I read the full diff and it contains only changes I intended.
  • I built the affected app(s) locally with no new analyzer warnings.
  • I ran the change in Business Central and confirmed it behaves as expected.
  • I added or updated tests for the new behavior.

What I tested and the outcome

  • al_build clean for the Shopify Connector (App) project. (The Shopify Connector Test project cannot be compiled on this local enlistment due to a known environmental symbol-cache issue; the test suite runs in CI.)
  • al_run_tests against Shpfy Order Refund Test (139611) in earlier iterations: 14/14 pass, including the new UnitTestCreateCrMemoFromRefundWithExchangeItem which asserts:
    • exactly two Type::Item sales lines on the credit memo with quantities +1 and -1;
    • no Type::"G/L Account" line pointing at Shop."Refund Account";
    • SalesHeader."Amount Including VAT" equals RefundHeader."Total Refunded Amount".
  • End-to-end re-validated against a live sandbox using the exact scenario from the bug report -- the credit memo now contains the two item lines totalling the refund amount, and the customer ledger settles to the exchange-item value only.

Risk & compatibility

  • New "Is Exchange Item" Boolean fields on Shpfy Order Line (id 22) and Shpfy Refund Line (id 14). Default false; existing rows behave unchanged.
  • Two new GraphQL queries against Shopify (one per phase). Both use read_returns / read_orders already covered by the connector's scope.
  • Fix only takes effect for orders/refunds (re)imported after deployment. Previously-imported refunds need re-import to pick up the synthetic exchange refund lines.
  • No data-upgrade work item required (additive fields only, no obsoletions).
  • No new analyzer warnings.
  • Surfaces the new flag on existing pages (Shpfy Order Subform, Shpfy Refund Lines).

…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>
@github-actions github-actions Bot added the AL: Apps (W1) Add-on apps for W1 label Jun 9, 2026
@github-actions github-actions Bot added this to the Version 29.0 milestone Jun 9, 2026
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>
@onbuyuka
onbuyuka marked this pull request as ready for review June 10, 2026 16:29
@onbuyuka
onbuyuka requested a review from a team as a code owner June 10, 2026 16:29
@github-actions

Copy link
Copy Markdown
Contributor

$\textbf{🟡\ Medium\ Severity\ —\ Performance} \quad \color{gray}{\texttt{\small Iteration\ 1}}$

DataCapture recorded before full line processing

In 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:

  • Move the DataCapture.Add call to after the foreach loop that processes all exchange line items, or capture at a more granular level inside FillInExchangeRefundLine (as is already done there).
// Move DataCapture.Add to after the foreach JExchangeLineItem loop:
foreach JExchangeLineItem in JExchangeLineItems do begin
    // ... process items ...
end;
DataCapture.Add(Database::"Shpfy Refund Header", RefundHeader.SystemId, JResponse);

Line mapping was unavailable, so this was posted as an issue comment.

👍 useful · ❤️ especially valuable · 👎 wrong - reply with why

@github-actions

Copy link
Copy Markdown
Contributor

⚠️ Stale Status Check Deleted

The Pull Request Build workflow run for this PR was older than 72 hours and has been deleted.

📋 Why was it deleted?

Status checks that are too old may no longer reflect the current state of the target branch. To ensure this PR is validated against the latest code and passes up-to-date checks, a fresh build is required.


🔄 How to trigger a new status check:

  1. 📤 Push a new commit to the PR branch, or
  2. 🔁 Close and reopen the PR

This will automatically trigger a new Pull Request Build workflow run.

@onbuyuka onbuyuka closed this Jun 15, 2026
@onbuyuka onbuyuka reopened this Jun 15, 2026
@onbuyuka onbuyuka closed this Jun 15, 2026
@onbuyuka onbuyuka reopened this Jun 15, 2026
@onbuyuka
onbuyuka enabled auto-merge (squash) June 15, 2026 20:23
@github-actions

Copy link
Copy Markdown
Contributor

$\textbf{🟠\ High\ Severity\ —\ Performance} \quad \color{gray}{\texttt{\small Iteration\ 1}}$

Extra GraphQL call for every imported order

MarkExchangeItemOrderLines 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:

  • Check for existing returns on the order header before making the API call, or add a first-pass check (e.g. whether Returns exist on the order) to skip the loop when there are none.
// Quick guard: exit early if the order has no returns recorded
if OrderHeader."Return Id" = 0 then
    exit;

Line mapping was unavailable, so this was posted as an issue comment.

👍 useful · ❤️ especially valuable · 👎 wrong - reply with why

@github-actions

Copy link
Copy Markdown
Contributor

$\textbf{🟡\ Medium\ Severity\ —\ Performance} \quad \color{gray}{\texttt{\small Iteration\ 1}}$

Synthetic refund line ID collision above 1000 line items

SyntheticExchangeRefundLineId 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:

  • Use a safer hashing or encoding scheme that avoids collisions regardless of list length, such as combining the IDs with a large prime multiplier or storing a counter-based synthetic ID that is guaranteed unique within the refund.
local procedure SyntheticExchangeRefundLineId(ExchangeLineItemId: BigInteger; LineItemIndex: Integer): BigInteger
begin
    // Use a large prime to minimise collision probability across any realistic data.
    exit(-((ExchangeLineItemId * 1000000) + LineItemIndex));
end;

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>
@onbuyuka

Copy link
Copy Markdown
Contributor Author

Thanks for the review. Pushed 17360cb addressing the actionable items; rationale for the rest below.

Applied

  • Extra GraphQL call on every order import (high, raised a few times): added an early-exit in MarkExchangeItemOrderLines that returns when OrderHeader.""Return Status"" is blank or No Return, so orders with no return never make the call.
  • SetLoadFields in the order-line scan: added OrderLine.SetLoadFields(""Line Id"", ""Is Exchange Item"").
  • Synthetic ID collision above 1000 line items: replaced the -(ExchangeLineItemId * 1000 + index) scheme with -OrderLineId. A Shopify line-item id is globally unique, so the negated value cannot collide with Shopify's positive refund-line ids or with another exchange line — the 1000-item ceiling is gone entirely and the helper/index bookkeeping is removed.
  • ""Is Exchange Item"" field Visible guard on Shpfy Refund Lines: set Visible = false.

Not changed (with rationale)

  • Pagination cursor set before hasNextPage check (both loops): this matches the established pattern used by every other paginated fetch in this codeunit (GetRefundLines, GetRefundShippingLines) and across the connector. The until not hasNextPage condition exits correctly regardless of the cursor value when there is no next page, so an empty After is never re-sent. Changing only the new loops would make them inconsistent with the surrounding code.
  • FindSet + Modify without LockTable: consistent with the adjacent ConsiderRefundsInQuantityAndAmounts, which modifies Shpfy Order Line the same way within the same single-session order-import flow. Order import is not run concurrently for the same order, so a table lock would only add contention.
  • Insert-then-Modify ""orphaned rows"": AL runs the whole import inside an implicit transaction, so an exception between Insert and Modify rolls the row back — no partial row persists. This also mirrors the existing FillInRefundLine.
  • GetRefundExchangeLines always makes one API call: the call site already guards with if RefundHeader.""Return Id"" <> 0, so refunds without a return make no call. The one remaining request is required to discover whether exchange line items exist.
  • DataCapture recorded before line processing: intentional — it stores the raw GraphQL response for diagnostics; the order relative to line processing is irrelevant.

@github-actions

Copy link
Copy Markdown
Contributor

⚠️ Stale Status Check Deleted

The Pull Request Build workflow run for this PR was older than 72 hours and has been deleted.

📋 Why was it deleted?

Status checks that are too old may no longer reflect the current state of the target branch. To ensure this PR is validated against the latest code and passes up-to-date checks, a fresh build is required.


🔄 How to trigger a new status check:

  1. 📤 Push a new commit to the PR branch, or
  2. 🔁 Close and reopen the PR

This will automatically trigger a new Pull Request Build workflow run.

@github-actions

github-actions Bot commented Jul 6, 2026

Copy link
Copy Markdown
Contributor

Copilot PR Review

Iteration 5 · Outcome: completed

Knowledge source: https://github.com/microsoft/BCQuality@822cae1b2771ac25f665f73369f69093bd4fd630

Findings by domain

Findings split into Knowledge-backed (cite a BCQuality article) and Agent (the agent's own judgement, no matching BCQuality rule).

Domain Findings Knowledge-backed Agent Inline Fallback
Agent 1 0 1 1 0

Totals: 0 knowledge-backed · 1 agent findings.

Orchestrator pre-filter (13 file(s) excluded)

  • layer-disabled (knowledge) : 13 file(s)

Findings produced by the Copilot CLI agent against BCQuality at 822cae1b2771ac25f665f73369f69093bd4fd630. Reply 👎 on any inline comment to flag false positives.

darjoo
darjoo previously approved these changes Jul 7, 2026
nikolakukrika
nikolakukrika previously approved these changes Jul 9, 2026
@onbuyuka
onbuyuka added this pull request to the merge queue Jul 9, 2026
@github-merge-queue
github-merge-queue Bot removed this pull request from the merge queue due to a conflict with the base branch Jul 9, 2026
@onbuyuka
onbuyuka enabled auto-merge July 9, 2026 12:36
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>
@onbuyuka
onbuyuka dismissed stale reviews from nikolakukrika and darjoo via 7ab9e00 July 9, 2026 12:40
AndreiPanko
AndreiPanko previously approved these changes Jul 9, 2026
…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>
@github-actions

Copy link
Copy Markdown
Contributor

⚠️ Stale Status Check Deleted

The Pull Request Build workflow run for this PR was older than 72 hours and has been deleted.

📋 Why was it deleted?

Status checks that are too old may no longer reflect the current state of the target branch. To ensure this PR is validated against the latest code and passes up-to-date checks, a fresh build is required.


🔄 How to trigger a new status check:

  1. 📤 Push a new commit to the PR branch, or
  2. 🔁 Close and reopen the PR

This will automatically trigger a new Pull Request Build workflow run.

@onbuyuka onbuyuka closed this Jul 14, 2026
auto-merge was automatically disabled July 14, 2026 07:56

Pull request was closed

@onbuyuka onbuyuka reopened this Jul 14, 2026
@onbuyuka
onbuyuka enabled auto-merge July 14, 2026 07:57
Parameters.Add('After', JsonHelper.GetValueAsText(JResponse, 'data.refund.return.exchangeLineItems.pageInfo.endCursor'));

foreach JExchangeLineItem in JExchangeLineItems do begin
ExchangeQuantity := JsonHelper.GetValueAsInteger(JExchangeLineItem, 'quantity');

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

$\textbf{🟡\ Medium\ Severity\ —\ Agent} \quad \color{gray}{\texttt{\small Iteration\ 5}}$

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

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This one looks make sense, should we consider to improve it?

@JesperSchulz JesperSchulz added the Integration GitHub request for Integration area label Jul 16, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

AL: Apps (W1) Add-on apps for W1 Integration GitHub request for Integration area

Projects

None yet

Development

Successfully merging this pull request may close these issues.

6 participants