Skip to content
Open
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
# cost: 15
{"query":"{ order(id: \"gid://shopify/Order/{{OrderId}}\") { returns(first: 10, after: \"{{After}}\") { pageInfo { endCursor hasNextPage } nodes { id exchangeLineItems(first: 250) { nodes { lineItems { id }}}}}}}"}
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
# cost: 15
{"query":"{ order(id: \"gid://shopify/Order/{{OrderId}}\") { returns(first: 10) { pageInfo { endCursor hasNextPage } nodes { id exchangeLineItems(first: 250) { nodes { lineItems { id }}}}}}}"}
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
# cost: 15
{"query":"{ refund(id: \"gid://shopify/Refund/{{RefundId}}\") { return { exchangeLineItems(first: 10, after: \"{{After}}\") { pageInfo { endCursor hasNextPage } nodes { id quantity variantId lineItems { id originalUnitPriceSet { presentmentMoney { amount } shopMoney { amount }} discountedUnitPriceSet { presentmentMoney { amount } shopMoney { amount }} totalDiscountSet { presentmentMoney { amount } shopMoney { amount }} taxLines { priceSet { presentmentMoney { amount } shopMoney { amount }}}}}}}}}"}
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
# cost: 15
{"query":"{ refund(id: \"gid://shopify/Refund/{{RefundId}}\") { return { exchangeLineItems(first: 10) { pageInfo { endCursor hasNextPage } nodes { id quantity variantId lineItems { id originalUnitPriceSet { presentmentMoney { amount } shopMoney { amount }} discountedUnitPriceSet { presentmentMoney { amount } shopMoney { amount }} totalDiscountSet { presentmentMoney { amount } shopMoney { amount }} taxLines { priceSet { presentmentMoney { amount } shopMoney { amount }}}}}}}}}"}
1 change: 1 addition & 0 deletions src/Apps/W1/Shopify/App/CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,7 @@ Records link to BC entities via SystemId (GUID), not Code/No. For example, `Shpf
- Records link to BC entities via SystemId (GUID), not Code/No. -- FlowFields like `"Item No."` display the human-readable values via CalcFormula lookup. Renumbering BC items does not break Shopify links.
- Orders store every monetary amount in dual currency: shop currency fields (`"Total Amount"`, `"VAT Amount"`) and presentment/customer-facing currency fields (`"Presentment Total Amount"`, `"Presentment VAT Amount"`). The `"Currency Handling"` setting on Shop controls which is used for BC documents.
- Returns and refunds are independent concepts in Shopify's model -- a refund can exist without a return and vice versa. The connector has three processing modes: import only, auto-create credit memo, and manual.
- Return-with-exchange flows: Shopify exposes exchange items on `Return.exchangeLineItems`, not on `Refund.refundLineItems`. The connector flags the matching `Shpfy Order Line` rows with `"Is Exchange Item" = true` so they are excluded from the BC sales invoice, and inserts negative-quantity `Shpfy Refund Line` rows so the credit memo total matches `Refund.totalRefundedSet` without a spurious *Refund Account* G/L balancing line.
- Fulfillment Orders (requests assigned to a location) are different from Fulfillments (actual shipments). Both have their own header/line tables.
- Negative IDs on records (metafields, addresses) indicate BC-created records not yet synced to Shopify. The OnInsert trigger assigns `Id := -1` (or decrements from the current minimum).
- Webhooks fan out to multiple BC companies -- the `ShpfyWebhookNotification` codeunit iterates all shops matching the webhook's Shopify URL, processing the notification once per shop/company.
Expand Down
11 changes: 11 additions & 0 deletions src/Apps/W1/Shopify/App/docs/data-model.md
Original file line number Diff line number Diff line change
Expand Up @@ -103,6 +103,17 @@ erDiagram

The restock type on refund lines is important for inventory: `Return` means the item is going back to stock at the return location, `Cancel` means the item was never shipped, and `NoRestock` means the refund is purely financial. The connector uses this to decide whether to create inventory adjustments.

### Return-with-exchange (negative-quantity refund lines)

When a customer returns an item *and* receives a different item in exchange, Shopify models the new item as an `ExchangeLineItem` on the originating `Return` -- *not* on the `Refund.refundLineItems` array. The `Refund.totalRefundedSet` already reflects the offset (cash refunded = returned value minus exchange value).

To make the BC sales credit memo total match `Refund.totalRefundedSet` *without* a spurious `Refund Account` G/L balancing line, the connector:

1. At order import time, fetches `Order.returns.nodes.exchangeLineItems.lineItems` and flags the matching `Shpfy Order Line` rows with `"Is Exchange Item" = true`. `ShpfyProcessOrder.CreateLinesFromShopifyOrder` excludes these flagged lines when projecting to BC `Sales Line` rows, so the BC sales invoice does not include the exchange item.
2. At refund import time, fetches `Refund.return.exchangeLineItems` and inserts one synthetic `Shpfy Refund Line` per `(ExchangeLineItem × lineItem)` pair with a **negative** `"Refund Line Id"` (to avoid colliding with real Shopify refund-line ids), `Quantity = -ExchangeLineItem.quantity`, `"Restock Type" = Return`, `"Is Exchange Item" = true`, and amounts that mirror the new item's price. The existing `CreateSalesLinesFromRefundLines` logic in `ShpfyCreateSalesDocRefund` emits a Type::Item credit-memo line with negative quantity that offsets the exchange value.

Net effect on the credit memo: positive-quantity item line for the returned item plus a negative-quantity item line for the exchange item, summing to `Refund.totalRefundedSet`. Net effect on BC inventory: the returned item flows back in via the positive credit-memo line; the exchange item leaves inventory via the negative credit-memo line (matching what Shopify physically shipped to the customer).

## Customer management

The Shopify Customer table mirrors Shopify's customer resource. It stores the Shopify customer ID, email, phone, name, and a `"Customer SystemId"` linking to the BC Customer table (again via GUID, with a FlowField for the human-readable number).
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -608,4 +608,20 @@ enum 30111 "Shpfy GraphQL Type"
{
Caption = 'Get Next Market Shipping Methods';
}
value(150; Orders_GetOrderExchangeLineItems)
{
Caption = 'Get Order Exchange Line Items';
}
value(151; Orders_GetNextOrderExchangeLineItems)
{
Caption = 'Get Next Order Exchange Line Items';
}
value(152; Refunds_GetRefundExchangeLines)
{
Caption = 'Get Refund Exchange Lines';
}
value(153; Refunds_GetNextRefundExchangeLines)
{
Caption = 'Get Next Refund Exchange Lines';
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,8 @@ codeunit 30228 "Shpfy Refunds API"
ReturnLocations := CollectReturnLocations(RefundHeader."Return Id");
GetRefundLines(RefundId, RefundHeader, ReturnLocations);
GetRefundShippingLines(RefundId);
if RefundHeader."Return Id" <> 0 then
GetRefundExchangeLines(RefundId, RefundHeader);
end;

local procedure GetRefundHeader(RefundId: BigInteger; UpdatedAt: DateTime; var RefundHeader: Record "Shpfy Refund Header")
Expand Down Expand Up @@ -155,6 +157,43 @@ codeunit 30228 "Shpfy Refunds API"
until not JsonHelper.GetValueAsBoolean(JResponse, 'data.refund.refundShippingLines.pageInfo.hasNextPage');
end;

local procedure GetRefundExchangeLines(RefundId: BigInteger; RefundHeader: Record "Shpfy Refund Header")
var
DataCapture: Record "Shpfy Data Capture";
GraphQLType: Enum "Shpfy GraphQL Type";
Parameters: Dictionary of [Text, Text];
JResponse: JsonToken;
JExchangeLineItems: JsonArray;
JExchangeLineItem: JsonToken;
JLineItems: JsonArray;
JLineItem: JsonToken;
ExchangeQuantity: Integer;
LineItemId: BigInteger;
begin
Parameters.Add('RefundId', Format(RefundId));
GraphQLType := "Shpfy GraphQL Type"::Refunds_GetRefundExchangeLines;
repeat
Comment thread
onbuyuka marked this conversation as resolved.
JResponse := CommunicationMgt.ExecuteGraphQL(GraphQLType, Parameters);
DataCapture.Add(Database::"Shpfy Refund Header", RefundHeader.SystemId, JResponse);
GraphQLType := "Shpfy GraphQL Type"::Refunds_GetNextRefundExchangeLines;
Comment thread
onbuyuka marked this conversation as resolved.
JExchangeLineItems := JsonHelper.GetJsonArray(JResponse, 'data.refund.return.exchangeLineItems.nodes');
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'));

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?

JLineItems := JsonHelper.GetJsonArray(JExchangeLineItem, 'lineItems');
foreach JLineItem in JLineItems do begin
LineItemId := CommunicationMgt.GetIdOfGId(JsonHelper.GetValueAsText(JLineItem, 'id'));
if LineItemId <> 0 then
FillInExchangeRefundLine(RefundHeader, LineItemId, ExchangeQuantity, JLineItem.AsObject());
end;
end;
until not JsonHelper.GetValueAsBoolean(JResponse, 'data.refund.return.exchangeLineItems.pageInfo.hasNextPage');
end;

local procedure CollectReturnLocations(ReturnId: BigInteger): Dictionary of [BigInteger, BigInteger]
var
ReturnsAPI: Codeunit "Shpfy Returns API";
Expand Down Expand Up @@ -243,6 +282,66 @@ codeunit 30228 "Shpfy Refunds API"
exit((RefundHeader."Return Id" > 0) or (RefundHeader."Total Refunded Amount" > 0));
end;

internal procedure FillInExchangeRefundLine(RefundHeader: Record "Shpfy Refund Header"; OrderLineId: BigInteger; ExchangeQuantity: Integer; JLineItem: JsonObject)
var
DataCapture: Record "Shpfy Data Capture";
RefundLine: Record "Shpfy Refund Line";
SyntheticRefundLineId: BigInteger;
UnitPriceShop: Decimal;
UnitPricePresentment: Decimal;
TotalDiscountShop: Decimal;
TotalDiscountPresentment: Decimal;
SubtotalShop: Decimal;
SubtotalPresentment: Decimal;
TotalTaxShop: Decimal;
TotalTaxPresentment: Decimal;
Comment thread
onbuyuka marked this conversation as resolved.
JTaxLines: JsonArray;
JTaxLine: JsonToken;
begin
if ExchangeQuantity <= 0 then
exit;

// A Shopify line item id is globally unique, so negating it yields a synthetic refund-line id
// that cannot collide with Shopify's positive refund-line ids or with another exchange line.
SyntheticRefundLineId := -OrderLineId;

UnitPriceShop := JsonHelper.GetValueAsDecimal(JLineItem, 'originalUnitPriceSet.shopMoney.amount');
UnitPricePresentment := JsonHelper.GetValueAsDecimal(JLineItem, 'originalUnitPriceSet.presentmentMoney.amount');
TotalDiscountShop := JsonHelper.GetValueAsDecimal(JLineItem, 'totalDiscountSet.shopMoney.amount');
TotalDiscountPresentment := JsonHelper.GetValueAsDecimal(JLineItem, 'totalDiscountSet.presentmentMoney.amount');
SubtotalShop := (UnitPriceShop * ExchangeQuantity) - TotalDiscountShop;
SubtotalPresentment := (UnitPricePresentment * ExchangeQuantity) - TotalDiscountPresentment;
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');
Comment thread
onbuyuka marked this conversation as resolved.
end;

if not RefundLine.Get(RefundHeader."Refund Id", SyntheticRefundLineId) then begin
RefundLine.Init();
RefundLine."Refund Line Id" := SyntheticRefundLineId;
RefundLine."Refund Id" := RefundHeader."Refund Id";
RefundLine."Order Line Id" := OrderLineId;
RefundLine.Insert();
end;

RefundLine."Restock Type" := RefundLine."Restock Type"::Return;
RefundLine.Quantity := -ExchangeQuantity;
RefundLine.Restocked := false;
RefundLine.Amount := UnitPriceShop;
RefundLine."Presentment Amount" := UnitPricePresentment;
RefundLine."Subtotal Amount" := -SubtotalShop;
RefundLine."Presentment Subtotal Amount" := -SubtotalPresentment;
RefundLine."Total Tax Amount" := -TotalTaxShop;
RefundLine."Presentment Total Tax Amount" := -TotalTaxPresentment;
RefundLine."Can Create Credit Memo" := true;
Comment thread
onbuyuka marked this conversation as resolved.
RefundLine."Location Id" := 0;
Comment thread
onbuyuka marked this conversation as resolved.
RefundLine."Is Exchange Item" := true;
RefundLine.Modify();

DataCapture.Add(Database::"Shpfy Refund Line", RefundLine.SystemId, JLineItem);
end;

local procedure UpdateTransactions(JRefund: JsonObject; RefundHeader: Record "Shpfy Refund Header")
var
OrderTransaction: Record "Shpfy Order Transaction";
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -99,6 +99,12 @@ page 30146 "Shpfy Refund Lines"
ApplicationArea = All;
ToolTip = 'Specifies whether the refunded line item was restocked.';
}
field("Is Exchange Item"; Rec."Is Exchange Item")
{
Comment thread
onbuyuka marked this conversation as resolved.
ApplicationArea = All;
Visible = false;
ToolTip = 'Specifies that this refund line was synthesized from a Return.exchangeLineItems entry. Exchange-item refund lines carry a negative quantity so that the credit memo total matches the Shopify refund total.';
}
}
}
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -107,6 +107,13 @@ table 30145 "Shpfy Refund Line"
DataClassification = SystemMetadata;
Editable = false;
}
field(14; "Is Exchange Item"; Boolean)
{
Caption = 'Is Exchange Item';
DataClassification = SystemMetadata;
Editable = false;
ToolTip = 'Specifies that this refund line was synthesized from a Return.exchangeLineItems entry rather than from a real Shopify refund line. Exchange-item refund lines carry a negative quantity so that the sales credit memo total matches the Shopify refund total without an extra balancing G/L line.';
}
field(101; "Item No."; Code[20])
{
Caption = 'Item No.';
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -195,7 +195,9 @@ codeunit 30246 "Shpfy Create Sales Doc. Refund"
SalesLine: Record "Sales Line";
GiftCard: Record "Shpfy Gift Card";
ShopLocation: Record "Shpfy Shop Location";
ExchangeOrderLine: Record "Shpfy Order Line";
OpenAmount: Decimal;
LocationId: BigInteger;
IsHandled: Boolean;
begin
repeat
Expand Down Expand Up @@ -234,7 +236,16 @@ codeunit 30246 "Shpfy Create Sales Doc. Refund"
if RefundLine."Variant Code" <> '' then
SalesLine.Validate("Variant Code", RefundLine."Variant Code");

if ShopLocation.Get(Shop.Code, RefundLine."Location Id") then
// Exchange-item refund lines are created before the order lines exist, so their Location Id
// is 0. Resolve it now from the exchange order line, which carries the fulfillment location.
LocationId := RefundLine."Location Id";
if RefundLine."Is Exchange Item" and (LocationId = 0) then begin
ExchangeOrderLine.SetLoadFields("Location Id");
if ExchangeOrderLine.Get(RefundHeader."Order Id", RefundLine."Order Line Id") then
LocationId := ExchangeOrderLine."Location Id";
end;

if ShopLocation.Get(Shop.Code, LocationId) then
SalesLine.Validate("Location Code", ShopLocation."Default Location Code");

if (Shop."Return Location Priority" = "Shpfy Return Location Priority"::"Default Return Location") or (SalesLine."Location Code" = '') then
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -122,6 +122,7 @@ codeunit 30161 "Shpfy Import Order"
OrderHeader.Modify();
OrderFulfillments.GetFulfillments(Shop, OrderHeader."Shopify Order Id");

MarkExchangeItemOrderLines(OrderHeader);
ConsiderRefundsInQuantityAndAmounts(OrderHeader);
DeleteZeroQuantityLines(OrderHeader);

Expand Down Expand Up @@ -209,6 +210,7 @@ codeunit 30161 "Shpfy Import Order"
if not IReturnRefundProcess.IsImportNeededFor("Shpfy Source Document Type"::Refund) then
exit;
OrderLine.SetRange("Shopify Order Id", OrderHeader."Shopify Order Id");
OrderLine.SetRange("Is Exchange Item", false);
if not OrderLine.FindSet() then
exit;
repeat
Expand Down Expand Up @@ -240,6 +242,68 @@ codeunit 30161 "Shpfy Import Order"
OrderHeader.Modify();
end;

internal procedure MarkExchangeItemOrderLines(var OrderHeader: Record "Shpfy Order Header")
Comment thread
onbuyuka marked this conversation as resolved.
var
OrderLine: Record "Shpfy Order Line";
IReturnRefundProcess: Interface "Shpfy IReturnRefund Process";
Comment thread
onbuyuka marked this conversation as resolved.
ExchangeLineIds: List of [BigInteger];
ExchangeLineId: BigInteger;
Comment thread
onbuyuka marked this conversation as resolved.
GraphQLType: Enum "Shpfy GraphQL Type";
Parameters: Dictionary of [Text, Text];
JResponse: JsonToken;
JReturns: JsonArray;
JReturn: JsonToken;
JExchangeLineItems: JsonArray;
JExchangeLineItem: JsonToken;
JLineItems: JsonArray;
JLineItem: JsonToken;
begin
IReturnRefundProcess := Shop."Return and Refund Process";
if not IReturnRefundProcess.IsImportNeededFor("Shpfy Source Document Type"::Refund) then
exit;

// Exchange line items only exist on orders that have a return. Skip the API call for the common case of no return.
if OrderHeader."Return Status" in [OrderHeader."Return Status"::" ", OrderHeader."Return Status"::"No Return"] then
exit;

Parameters.Add('OrderId', Format(OrderHeader."Shopify Order Id"));
GraphQLType := "Shpfy GraphQL Type"::Orders_GetOrderExchangeLineItems;
repeat
JResponse := CommunicationMgt.ExecuteGraphQL(GraphQLType, Parameters);
GraphQLType := "Shpfy GraphQL Type"::Orders_GetNextOrderExchangeLineItems;
JReturns := JsonHelper.GetJsonArray(JResponse, 'data.order.returns.nodes');
if Parameters.ContainsKey('After') then
Comment thread
onbuyuka marked this conversation as resolved.
Parameters.Set('After', JsonHelper.GetValueAsText(JResponse, 'data.order.returns.pageInfo.endCursor'))
else
Parameters.Add('After', JsonHelper.GetValueAsText(JResponse, 'data.order.returns.pageInfo.endCursor'));

foreach JReturn in JReturns do begin
JExchangeLineItems := JsonHelper.GetJsonArray(JReturn, 'exchangeLineItems.nodes');
foreach JExchangeLineItem in JExchangeLineItems do begin
JLineItems := JsonHelper.GetJsonArray(JExchangeLineItem, 'lineItems');
foreach JLineItem in JLineItems do begin
ExchangeLineId := CommunicationMgt.GetIdOfGId(JsonHelper.GetValueAsText(JLineItem, 'id'));
if (ExchangeLineId <> 0) and not ExchangeLineIds.Contains(ExchangeLineId) then
ExchangeLineIds.Add(ExchangeLineId);
end;
end;
end;
until not JsonHelper.GetValueAsBoolean(JResponse, 'data.order.returns.pageInfo.hasNextPage');

if ExchangeLineIds.Count() = 0 then
exit;
Comment thread
onbuyuka marked this conversation as resolved.

OrderLine.SetRange("Shopify Order Id", OrderHeader."Shopify Order Id");
OrderLine.SetLoadFields("Line Id", "Is Exchange Item");
if OrderLine.FindSet() then
Comment thread
onbuyuka marked this conversation as resolved.
repeat
if ExchangeLineIds.Contains(OrderLine."Line Id") and (not OrderLine."Is Exchange Item") then begin
Comment thread
onbuyuka marked this conversation as resolved.
OrderLine."Is Exchange Item" := true;
OrderLine.Modify();
end;
until OrderLine.Next() = 0;
Comment thread
onbuyuka marked this conversation as resolved.
end;

local procedure IsImportedOrderConflictingExistingOrder(JOrder: JsonObject; OrderHeader: Record "Shpfy Order Header"; var TempOrderLine: Record "Shpfy Order Line" temporary): Boolean
var
Hash: Codeunit "Shpfy Hash";
Expand Down
Loading
Loading