Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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 @@ -600,4 +600,20 @@ enum 30111 "Shpfy GraphQL Type"
{
Caption = 'Has Fulfillment Service';
}
value(148; Orders_GetOrderExchangeLineItems)
{
Caption = 'Get Order Exchange Line Items';
}
value(149; Orders_GetNextOrderExchangeLineItems)
{
Caption = 'Get Next Order Exchange Line Items';
}
value(150; Refunds_GetRefundExchangeLines)
{
Caption = 'Get Refund Exchange Lines';
}
value(151; Refunds_GetNextRefundExchangeLines)
{
Caption = 'Get Next Refund Exchange Lines';
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,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 @@ -134,6 +136,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');
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 @@ -222,6 +261,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;
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;
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 @@ -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;
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

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\ —\ Style} \quad \color{gray}{\texttt{\small Iteration\ 1}}$

Exchange-item flag never cleared on re-import

MarkExchangeItemOrderLines sets "Is Exchange Item" := true for lines identified as exchange items but never sets it back to false for lines that were previously marked as exchange items but whose associated return was subsequently cancelled or removed in Shopify. After a return cancellation, those order lines will remain permanently marked as exchange items, causing them to be incorrectly excluded from BC sales documents on every subsequent re-import.

Recommendation:

  • Before re-marking, reset all existing exchange-item flags for the order to false, then re-apply the flags based on the current API response.
Suggested change
if ExchangeLineIds.Contains(OrderLine."Line Id") and (not OrderLine."Is Exchange Item") then begin
// Before the ExchangeLineIds loop:
OrderLine.SetRange("Shopify Order Id", OrderHeader."Shopify Order Id");
OrderLine.SetRange("Is Exchange Item", true);
if OrderLine.FindSet(true) then
repeat
OrderLine."Is Exchange Item" := false;
OrderLine.Modify();
until OrderLine.Next() = 0;
OrderLine.SetRange("Is Exchange Item");

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

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
Original file line number Diff line number Diff line change
Expand Up @@ -191,6 +191,7 @@ codeunit 30166 "Shpfy Process Order"
Discount: Decimal;
begin
OrderLine.SetRange("Shopify Order Id", OrderHeader."Shopify Order Id");
OrderLine.SetRange("Is Exchange Item", false);
OrderLine.CalcSums("Discount Amount");
OrderShippingCharges.SetRange("Shopify Order Id", OrderHeader."Shopify Order Id");
OrderShippingCharges.CalcSums("Discount Amount");
Expand Down Expand Up @@ -231,6 +232,7 @@ codeunit 30166 "Shpfy Process Order"
SalesLine.Insert(true);
end;
ShopifyOrderLine.SetRange("Shopify Order Id", ShopifyOrderHeader."Shopify Order Id");
ShopifyOrderLine.SetRange("Is Exchange Item", false);
if ShopifyOrderLine.FindSet() then
repeat
OrderEvents.OnBeforeCreateItemSalesLine(ShopifyOrderHeader, ShopifyOrderLine, SalesHeader, SalesLine, IsHandled);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -115,6 +115,12 @@ page 30122 "Shpfy Order Subform"
Editable = false;
ToolTip = 'Specifies the quantity available to fulfill.';
}
field("Is Exchange Item"; Rec."Is Exchange Item")
{
ApplicationArea = All;
Editable = false;
ToolTip = 'Specifies whether this line was added as the new item in a return-with-exchange. Exchange items are not included in the BC sales document created from the Shopify order; their value is offset on the credit memo by a negative-quantity refund line.';
}
}
}
}
Expand Down
Loading