diff --git a/src/Apps/W1/Shopify/App/CLAUDE.md b/src/Apps/W1/Shopify/App/CLAUDE.md new file mode 100644 index 0000000000..c5a6e28a0a --- /dev/null +++ b/src/Apps/W1/Shopify/App/CLAUDE.md @@ -0,0 +1,69 @@ +# Shopify Connector + +The Shopify Connector bridges Shopify e-commerce with Business Central ERP, bi-directionally synchronizing products, customers, companies, orders, inventory, and fulfillments. It uses Shopify's GraphQL Admin API exclusively -- there is no REST API usage. The connector enables merchants to manage their online storefront from within Business Central, treating Shopify as the storefront and BC as the system of record for inventory, pricing, and financials. + +## Quick reference + +- **ID range**: 30100--30460 +- **Namespace**: Microsoft.Integration.Shopify + +## How it works + +The Shop table (`ShpfyShop.Table.al`, ID 30102) is the god object. Nearly every configuration setting lives there: sync directions for items/customers/companies, mapping strategies, customer/item template codes, G/L account mappings for shipping/tips/gift cards/refunds, B2B flags, currency handling, webhook settings, and fulfillment service configuration. A single BC company can connect to multiple Shopify shops (each with its own Shop Code), and each shop gets its own set of configuration. + +All Shopify API communication goes through GraphQL. The `ShpfyCommunicationMgt.Codeunit.al` is the single entry point for API calls. It constructs URLs using a versioned API path (currently `2026-01`), handles authentication, rate limiting, and retry logic. The 145 codeunit files in `src/GraphQL/Codeunits/` each implement the `IGraphQL` interface, which requires two methods: `GetGraphQL()` returning the query text and `GetExpectedCost()` returning the estimated query cost. The `ShpfyGraphQLRateLimit` codeunit (singleton) tracks Shopify's cost-based throttle -- it reads `restoreRate` and `currentlyAvailable` from responses and sleeps before issuing requests that would exceed the budget. + +Mapping strategies are interface-driven throughout. Customer mapping (`ICustomerMapping`) selects between by-email/phone, by-bill-to, or default-customer strategies. Company mapping (`ICompanyMapping`) can match by email/phone or tax ID. Stock calculation uses `IStockAvailable` and `IStockCalculation` interfaces. Product status on creation, removal actions for blocked items, county resolution, and customer name formatting are all interface-backed enums. The Shop record's enum fields (e.g., `"Customer Mapping Type"`, `"Stock Calculation"`, `"Status for Created Products"`) select which implementation to use at runtime. + +Sync is incremental via the Synchronization Info table (`ShpfySynchronizationInfo.Table.al`), which stores the last sync timestamp per shop and sync type. An empty/zero date falls back to a sentinel value of `2004-01-01` (see `GetEmptySyncTime()`). Products use hash-based change detection -- the Product table stores `"Image Hash"`, `"Tags Hash"`, and `"Description Html Hash"` fields, computed via a custom hash algorithm in `ShpfyHash`, to avoid unnecessary API calls when nothing has actually changed. + +Records link to BC entities via SystemId (GUID), not Code/No. For example, `Shpfy Product` has an `"Item SystemId"` field linking to BC Items, with `"Item No."` as a FlowField that looks up the human-readable code. This means renumbering items in BC does not break Shopify links. The same pattern applies to variants (`"Item Variant SystemId"`) and customers. B2B support adds companies, company locations, and catalogs with company-specific pricing, all orchestrated through the `src/Companies/` and `src/Catalogs/` modules. + +## Structure + +- `src/Base/` -- Shop configuration, installer, sync info, tags, communication infrastructure, guided experience +- `src/Products/` -- Product/variant sync (both directions), image sync, collections, price calculation, SKU mapping +- `src/Order handling/` -- Order import from Shopify, customer/item mapping, sales document creation, order attributes +- `src/Customers/` -- Customer sync, mapping strategies (by email/phone, bill-to, default), name formatting, country/province data +- `src/Companies/` -- B2B company and company location management, company mapping strategies, tax ID mapping +- `src/Catalogs/` -- B2B catalog and catalog pricing management +- `src/Inventory/` -- Stock level sync to Shopify, location mapping, stock calculation strategies +- `src/GraphQL/` -- 145 query builder codeunits implementing IGraphQL, rate limiting, query management +- `src/Metafields/` -- Extensible custom field system with polymorphic owners and typed values +- `src/Order Fulfillments/` -- Fulfillment order headers/lines and actual fulfillment records +- `src/Order Returns/` -- Return headers and lines from Shopify +- `src/Order Refunds/` -- Refund headers, lines, and shipping lines +- `src/Order Return Refund Processing/` -- Processing strategies (import only, auto-create credit memo) with IReturnRefundProcess interface +- `src/Payments/` -- Payouts and disputes +- `src/Transactions/` -- Order transactions (payment events) +- `src/Gift Cards/` -- Gift card handling +- `src/Document Links/` -- Bidirectional links between Shopify documents and BC documents +- `src/Webhooks/` -- Webhook subscription management and notification processing +- `src/Bulk Operations/` -- Async bulk mutation framework with webhook callback +- `src/Logs/` -- Activity log entries for debugging +- `src/Shipping/` -- Shipping method mapping +- `src/Invoicing/` -- Posted invoice sync +- `src/Helpers/` -- JSON helper, hash algorithm, skipped record tracking + +## Documentation + +- [docs/data-model.md](docs/data-model.md) -- How the data fits together +- [docs/business-logic.md](docs/business-logic.md) -- Processing flows and gotchas +- [docs/extensibility.md](docs/extensibility.md) -- Extension points and how to customize +- [docs/patterns.md](docs/patterns.md) -- Recurring code patterns (and legacy ones to avoid) + +## Things to know + +- The Shop table is the god object -- nearly every configuration setting lives there, with over 100 fields controlling sync directions, mapping strategies, account mappings, B2B flags, webhook config, and more. +- All API calls go through GraphQL, never REST. 145 codeunits in `src/GraphQL/Codeunits/` implement `IGraphQL` for type-safe query building with cost estimation. +- Products use hash-based change detection (`"Image Hash"`, `"Tags Hash"`, `"Description Html Hash"`) via a custom hash algorithm to skip unnecessary API calls when nothing has changed. +- 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. +- 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. +- Metafields use a polymorphic owner pattern (`"Parent Table No."` + `"Owner Id"`) to attach to products, variants, customers, or companies. The `IMetafieldOwnerType` interface resolves the table and shop code. +- Gift cards appear as both an order line type (when purchased) and a payment method (when redeemed). They have a dedicated G/L account (`"Sold Gift Card Account"`) on the Shop table. +- Bulk operations use async GraphQL mutations with webhook callback -- `IBulkOperation` implementations provide the mutation, input JSONL, and revert logic for failures. +- The empty sync time sentinel is `2004-01-01` (the `GetEmptySyncTime()` method), not `0DT`. Order sync uses the Shop's `"Shop Id"` hash as the sync key (not the Shop Code) so that multiple BC companies connected to the same Shopify shop share the same sync cursor. diff --git a/src/Apps/W1/Shopify/App/docs/business-logic.md b/src/Apps/W1/Shopify/App/docs/business-logic.md new file mode 100644 index 0000000000..8e59d37cca --- /dev/null +++ b/src/Apps/W1/Shopify/App/docs/business-logic.md @@ -0,0 +1,138 @@ +# Business logic + +This document covers the major processing flows in the Shopify Connector, focusing on decision points, non-obvious behavior, and what can go wrong. + +## Product synchronization + +Product sync is bi-directional, controlled by the Shop's `"Sync Item"` setting (To Shopify, From Shopify, or disabled). The two directions have fundamentally different architectures. + +### BC to Shopify export + +The export flow is driven by `ShpfyProductExport.Codeunit.al`. It iterates all Shopify Product records that have a linked BC Item (non-empty `"Item SystemId"`) and belong to the current shop. For each product, it checks whether the Shop allows updates (`"Can Update Shopify Products"` flag), then calls `UpdateProductData()`. + +The update flow relies heavily on hash-based change detection. Before making any API call, the codeunit computes the current hash of the product's tags, HTML description, and images, then compares them against the stored hashes on the Product record. Only fields that have actually changed result in API calls. This is critical because Shopify's GraphQL API has per-store rate limits, and a full product catalog update without change detection would quickly exhaust the budget. + +Price sync can run independently of the full product sync via the `OnlyUpdatePrice` flag. When enabled, prices are batched into a bulk mutation (`ShpfyBulkUpdateProductPrice.Codeunit.al`) for efficiency. If the bulk operation fails, it falls back to individual variant price updates, reverting variant changes on failure. + +The product body HTML is assembled from BC data by `CreateProductBody()` in the export codeunit -- it concatenates extended text, marketing text, and item attributes into an HTML structure. Events fire before and after this assembly (`OnBeforeCreateProductBodyHtml`, `OnAfterCreateProductBodyHtml`), allowing extensions to customize the output. + +### Shopify to BC import + +The import direction is handled by `ShpfyProductImport.Codeunit.al`. It fetches products from Shopify using timestamp-based incremental sync, creates or updates local Product and Variant records, and optionally creates BC Items. + +Item creation uses the template system -- the Shop's `"Item Templ. Code"` provides defaults, and the `OnAfterFindItemTemplate` event allows per-product template selection. `ShpfyCreateItem.Codeunit.al` handles the actual item creation. Variant mapping is complex because Shopify variants carry option values (size/color) that may map to BC Item Variants, and the `"UoM as Variant"` setting can make unit-of-measure values appear as variant options. + +```mermaid +flowchart TD + A[Start product sync] --> B{Sync direction?} + B -->|To Shopify| C[Load products with linked items] + B -->|From Shopify| D[Fetch updated products from API] + C --> E{Product changed?} + E -->|Hash matches| F[Skip] + E -->|Hash differs| G[Update product via API] + G --> H{Price only?} + H -->|Yes| I[Batch into bulk mutation] + H -->|No| J[Full product update] + D --> K{Product exists locally?} + K -->|Yes| L[Update local record] + K -->|No| M{Auto create items?} + M -->|Yes| N[Create BC Item from template] + M -->|No| O[Store product without item link] +``` + +A product that is blocked or sales-blocked in BC gets its Shopify status changed according to the `"Action for Removed Products"` setting. The `IRemoveProductAction` interface drives this -- implementations include moving to Draft, Archived, or doing nothing. This also fires when a product is deleted from the local table (via the OnDelete trigger on `ShpfyProduct.Table.al`). + +## Order import and processing + +Order processing is the most complex flow in the connector. It spans multiple codeunits and involves several mapping steps before a BC sales document can be created. + +### Discovery and staging + +`ShpfyOrdersAPI.Codeunit.al` queries Shopify for orders updated since the last sync time. New orders are inserted into the Orders to Import staging table. The `"Order Created Webhooks"` setting can supplement polling -- when enabled, Shopify pushes real-time notifications that trigger immediate order import. + +### Import + +`ShpfyImportOrder.Codeunit.al` takes an order from the staging table and creates the full Order Header and Order Lines. It parses the GraphQL response to populate all address blocks, financial fields (in both currencies), customer IDs, discount codes, attributes, tax lines, and risk assessments. The `OnAfterImportShopifyOrderHeader` and `OnAfterCreateShopifyOrderAndLines` events fire after import. + +### Mapping + +Before a sales document can be created, the order must be mapped to BC entities. `ShpfyOrderMapping.Codeunit.al` orchestrates this. The mapping proceeds in sequence: customer mapping, then shipment method, shipping agent, and payment method. Each step has Before/After events that allow extensions to override or supplement the logic. + +Customer mapping is the most involved step. For B2B orders (where `"Company Id"` is set), the company mapping strategy runs instead. For D2C orders, the `ICustomerMapping` interface dispatches based on the Shop's `"Customer Mapping Type"` enum -- options include By Email/Phone, By Bill-to Info, and Default Customer. If the customer cannot be found and `"Auto Create Unknown Customers"` is enabled, a new BC customer is created from the template. + +### Sales document creation + +`ShpfyProcessOrder.Codeunit.al` creates the BC sales document. It first confirms mapping succeeded, then creates the Sales Header, Sales Lines, and applies global discounts. + +```mermaid +flowchart TD + A[Order imported] --> B[Run mapping] + B --> C{Customer mapped?} + C -->|No| D{Auto create?} + D -->|No| E[Error: not mapped] + D -->|Yes| F[Create customer from template] + C -->|Yes| G[Map shipment method] + F --> G + G --> H[Map payment method] + H --> I{Fully fulfilled?} + I -->|Yes, and setting on| J[Create Sales Invoice] + I -->|No| K[Create Sales Order] + J --> L[Create lines] + K --> L + L --> M[Apply global discounts] + M --> N{Auto release?} + N -->|Yes| O[Release document] + N -->|No| P[Done] +``` + +The header creation in `CreateHeaderFromShopifyOrder()` is notably manual -- it directly assigns address fields with `CopyStr()` truncation rather than using standard BC validation, because the Shopify data may not conform to BC's address validation rules. Currency handling branches on the Shop's `"Currency Handling"` setting: either the shop's configured currency code or the order's presentment currency. + +The `"Create Invoices From Orders"` setting causes fully-fulfilled orders to be created as Sales Invoices instead of Sales Orders. The `"Use Shopify Order No."` setting uses the Shopify order number (e.g., "#1001") as the BC document number, which requires the number series to have Manual Nos. enabled. + +Special line types require attention: gift card purchases map to the `"Sold Gift Card Account"` G/L account, tips map to `"Tip Account"`, and shipping charges map to `"Shipping Charges Account"`. These are all configured on the Shop record. + +## Fulfillment + +Fulfillment sync runs when BC sales shipments are posted. `ShpfyOrderFulfillments.Codeunit.al` creates fulfillment records in Shopify from the posted shipment data, including tracking numbers and carrier information. + +The connector registers itself as a Shopify Fulfillment Service (controlled by `"Fulfillment Service Activated"` on the Shop), which causes Shopify to create Fulfillment Order Headers assigned to BC's virtual location. When a shipment posts, the connector accepts the fulfillment request and creates the actual fulfillment with `ShpfyFulfillmentOrdersAPI.Codeunit.al`. + +The `"Send Shipping Confirmation"` setting controls whether Shopify sends a shipping notification email to the customer when the fulfillment is created. This is a Shop-level setting, not per-fulfillment. + +## Returns and refunds + +Returns and refunds are fetched from Shopify and processed according to the Shop's `"Return and Refund Process"` setting. This is one of the more nuanced flows because returns and refunds are independent in Shopify's model. + +`ShpfyReturnsAPI.Codeunit.al` fetches return data and populates Return Header and Return Line records. `ShpfyRefundsAPI.Codeunit.al` does the same for refunds. Both are incremental, driven by the order's `"Updated At"` timestamp. + +The processing strategy is selected by the `"Return and Refund Process"` enum on the Shop, which dispatches through the `IReturnRefundProcess` interface. Three implementations exist: + +- **Import Only** (`ShpfyRetRefProcImportOnly.Codeunit.al`) -- fetches and stores the data but creates no BC documents. The user manually creates credit memos. +- **Auto Create Credit Memo** (`ShpfyRetRefProcCrMemo.Codeunit.al`) -- automatically creates BC Sales Credit Memos (or Return Orders, based on `"Process Returns As"` setting) from the refund data. This requires `"Auto Create Orders"` to be enabled. +- **Default** (`ShpfyRetRefProcDefault.Codeunit.al`) -- no-op processing. + +The `IDocumentSource` interface determines which Shopify document (return or refund) provides the line data for the BC credit memo. The default implementation sources from refund lines, but this can be overridden. + +Refund lines carry a `"Restock Type"` that affects inventory: Return means items go back to stock, Cancel means they were never shipped, NoRestock is purely financial. Lines with non-restock types are mapped to G/L accounts (`"Refund Acc. non-restock Items"`) instead of item lines on the credit memo. + +## Inventory synchronization + +Inventory sync pushes stock levels from BC to Shopify. It is always one-directional (BC to Shopify). `ShpfySyncInventory.Codeunit.al` orchestrates the flow. + +For each product variant with inventory tracking enabled, the connector calculates the available stock using the `IStockCalculation` interface. The Shop Location mapping determines which BC locations contribute stock to each Shopify location, and the stock calculation strategy (configured per Shop Location via the `"Stock Calculation"` enum) determines the calculation method. + +Built-in strategies include `ShpfyBalanceToday.Codeunit.al` (projected available balance) and `ShpfyFreeInventory.Codeunit.al` (current physical inventory minus reserved). The `IStockAvailable` interface controls whether a given inventory management setting allows stock tracking at all. + +Stock levels are set via the `ShpfyInventoryAPI.Codeunit.al` which calls Shopify's `inventorySetQuantities` mutation. The connector uses Shopify's "available" quantity name and sets it absolutely (not as a delta). + +## Customer and company synchronization + +Customer sync can run in both directions, controlled by `"Shopify Can Update Customer"` and `"Can Update Shopify Customer"` flags (which are mutually exclusive -- enabling one disables the other). + +Import from Shopify is handled by `ShpfyCustomerImport.Codeunit.al`. The `"Customer Import From Shopify"` setting controls when customers are imported: with order import only, or as a separate sync operation. When auto-creating BC customers, the connector uses the Customer Template system -- the `ShpfyCustomerTemplate.Table.al` allows different templates per country, falling back to the Shop's `"Customer Templ. Code"`. + +Export to Shopify is handled by `ShpfyCustomerExport.Codeunit.al`. It creates or updates Shopify customers from BC customer records. + +Company sync follows the same pattern but with `ShpfyCompanyImport.Codeunit.al` and `ShpfyCompanyExport.Codeunit.al`. Company mapping uses `ICompanyMapping` with strategies including By Email/Phone, By Tax ID, and Default Company. The B2B chain is: Company -> Company Location -> Customer (the location's main contact becomes the BC customer). + +When a B2B order arrives, the customer resolution differs from D2C: the connector looks up the company's main contact customer ID and the company location to determine the sell-to and ship-to customer numbers. This can result in different BC customers for different company locations of the same Shopify company. diff --git a/src/Apps/W1/Shopify/App/docs/data-model.md b/src/Apps/W1/Shopify/App/docs/data-model.md new file mode 100644 index 0000000000..ffc1d9b349 --- /dev/null +++ b/src/Apps/W1/Shopify/App/docs/data-model.md @@ -0,0 +1,187 @@ +# Data model + +This document covers how the Shopify Connector's data fits together. Each section explains a conceptual domain, includes an ER diagram, and highlights non-obvious design decisions. + +## Configuration + +The Shop table is the center of the configuration universe. Every sync operation, mapping strategy, webhook setting, and G/L account mapping lives on a single Shop record identified by a `Code[20]`. A BC company can connect to multiple Shopify shops, but each shop has exactly one configuration. + +Synchronization Info stores the last sync timestamp per shop and sync type (products, customers, orders, inventory, etc.). It is keyed on Shop Code + Synchronization Type. For order sync specifically, the key is the Shop's integer hash (`"Shop Id"`) rather than the Shop Code -- this allows multiple BC companies connected to the same Shopify store to share one order sync cursor without re-importing the same orders. + +Shop Location maps Shopify fulfillment locations to BC warehouse locations. Each mapping includes a stock calculation enum that determines how to compute available stock for that pairing. + +```mermaid +erDiagram + SHOP ||--o{ SYNCHRONIZATION_INFO : tracks + SHOP ||--o{ SHOP_LOCATION : maps + SHOP ||--o{ PRODUCT : contains + SHOP ||--o{ ORDER_HEADER : contains +``` + +The Shop's `GetEmptySyncTime()` returns `2004-01-01` as a sentinel for "never synced" -- not `0DT`. This date is far enough in the past to import all data on first sync but avoids edge cases with zero-date handling in AL. The `SetLastSyncTime()` method stores `CurrentDateTime` after a successful sync. When the next sync runs, it passes this timestamp to Shopify's `updated_at` filter to fetch only changed records. + +## Product catalog + +A Shopify product is a parent container holding one or more variants. In BC terms, a Product maps to an Item, and Variants map to Item Variants. The Product table stores the Shopify product ID (BigInteger), description, title, status, and crucially three hash fields: `"Image Hash"`, `"Tags Hash"`, and `"Description Html Hash"`. These enable the export flow to skip API calls when nothing has changed. + +Each Variant belongs to a Product (via `"Product Id"`) and can map to both an Item and an Item Variant via their respective SystemIds. Variants carry the pricing fields (Price, Compare at Price, Unit Cost) and up to three option name/value pairs that represent Shopify's variant options (size, color, etc.). + +Inventory Items represent the inventory-trackable entries for each variant at each Shopify location. Shop Inventory combines the location mapping with the calculated stock level. + +Product Collections model Shopify's collections concept (manual or automated groupings of products). Shop Collection Map links a Shopify collection to a BC entity like a Tax Group or VAT Product Posting Group, driven by the Shop's `"Product Collection"` setting. + +```mermaid +erDiagram + PRODUCT ||--o{ VARIANT : has + VARIANT ||--o{ INVENTORY_ITEM : tracked_at + PRODUCT ||--o{ PRODUCT_COLLECTION : belongs_to + SHOP ||--o{ SHOP_COLLECTION_MAP : maps +``` + +The `"Item SystemId"` field on both Product and Variant is a GUID linking to BC's Item table. The `"Item No."` FlowField resolves this to the human-readable item number via a CalcFormula lookup. This design means renumbering items in BC does not break the link. The same pattern applies to `"Item Variant SystemId"` on Variant. + +The HTML description is stored in a Blob field (`"Description as HTML"`) because AL's Text fields cap at 2048 characters and Shopify product descriptions can be substantially longer. The hash is computed when the description is set (via `SetDescriptionHtml()`) and compared during export to avoid unnecessary API updates. + +## Order lifecycle + +Orders flow through a staging pipeline: first they appear in Orders to Import (lightweight records with just the Shopify order ID and basic metadata), then they are imported into Order Header and Order Lines, and finally processed into BC Sales Orders or Sales Invoices. + +The Order Header is one of the largest tables in the connector. It stores complete sell-to, ship-to, and bill-to address blocks, financial summaries in dual currency (shop currency and presentment currency), status tracking, customer mappings, B2B fields (company ID, company location ID, PO number), and processing state flags. + +Order Lines carry the item-level detail including the Shopify variant ID, mapped BC item number, quantities, and dual-currency amounts. Special line types include tips and gift cards, identified by boolean flags. + +Order Attributes and Order Line Attributes store Shopify's custom attributes (key-value pairs that merchants can attach to orders). Order Tax Lines track per-tax-rate amounts, including a `"Channel Liable"` flag for marketplace tax collection. + +```mermaid +erDiagram + ORDERS_TO_IMPORT ||--|| ORDER_HEADER : imports_to + ORDER_HEADER ||--o{ ORDER_LINE : contains + ORDER_HEADER ||--o{ ORDER_ATTRIBUTE : has + ORDER_HEADER ||--o{ ORDER_TAX_LINE : taxed_by +``` + +The dual currency design deserves attention. Every monetary field on the Order Header exists twice: once in the shop's base currency (`"Total Amount"`, `"Discount Amount"`, etc.) and once in the presentment currency (`"Presentment Total Amount"`, `"Presentment Discount Amount"`, etc.). The Shop's `"Currency Handling"` setting controls which set of amounts is used when creating BC sales documents. The `"Processed Currency Handling"` field on the order captures which mode was actually used, so re-processing uses the same logic. + +## Fulfillment + +Shopify's fulfillment model has two layers: Fulfillment Orders (requests to fulfill from a specific location) and Fulfillments (actual shipments). This maps to two pairs of header/line tables. + +Fulfillment Order Headers represent the fulfillment requests assigned to locations. They track the status (Open, InProgress, Closed, etc.) and the assigned location. Fulfillment Order Lines track which order line items are included and how many units. + +Order Fulfillments represent actual shipments. When BC posts a sales shipment, the connector creates a fulfillment in Shopify with tracking information. Fulfillment Lines detail the specific items and quantities shipped. + +```mermaid +erDiagram + ORDER_HEADER ||--o{ FULFILLMENT_ORDER_HEADER : requests + FULFILLMENT_ORDER_HEADER ||--o{ FULFILLMENT_ORDER_LINE : contains + ORDER_HEADER ||--o{ ORDER_FULFILLMENT : fulfilled_by + ORDER_FULFILLMENT ||--o{ FULFILLMENT_LINE : contains +``` + +The distinction matters operationally: a fulfillment order is "please ship these items from this location" while a fulfillment is "these items were actually shipped with this tracking number." An order can have fulfillment orders at multiple locations, and each can result in separate fulfillments. + +## Returns and refunds + +Returns and refunds are independent concepts in Shopify's data model. A return tracks the physical merchandise coming back (customer initiated a return, items are in transit or received). A refund tracks the financial reversal (money back to the customer). You can have a refund without a return (e.g., goodwill credit) or a return without a refund (e.g., exchange). + +Return Headers link to the originating order and carry status information. Return Lines detail which items are being returned, the quantity, and the return reason. + +Refund Headers also link to the originating order and carry a note and creation timestamp. Refund Lines track the refunded quantities, amounts in dual currency, and the restock type (Return, Cancel, NoRestock). Refund Shipping Lines handle refunded shipping costs separately. + +```mermaid +erDiagram + ORDER_HEADER ||--o{ RETURN_HEADER : has + RETURN_HEADER ||--o{ RETURN_LINE : contains + ORDER_HEADER ||--o{ REFUND_HEADER : has + REFUND_HEADER ||--o{ REFUND_LINE : contains + REFUND_HEADER ||--o{ REFUND_SHIPPING_LINE : refunds +``` + +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. + +## 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). + +Customer Addresses hold the customer's saved addresses. Provinces store Shopify's province/state data for country-region resolution. Tax Areas map Shopify's tax jurisdictions to BC's tax area codes, used when creating sales documents. + +```mermaid +erDiagram + CUSTOMER ||--o{ CUSTOMER_ADDRESS : has + CUSTOMER }o--|| SHOP : belongs_to + PROVINCE }o--|| SHOP : belongs_to + TAX_AREA }o--|| SHOP : belongs_to +``` + +The Customer Template table (`ShpfyCustomerTemplate.Table.al`) allows different BC customer templates to be selected based on the Shopify customer's country -- this is how the connector handles per-country posting group assignments when auto-creating BC customers. + +## B2B companies + +Companies model Shopify's B2B entities. A Company has one or more Company Locations (analogous to ship-to addresses), each of which can have its own customer mapping in BC. Catalogs provide company-specific product assortments and pricing. + +The Company table stores the Shopify company ID, name, and a `"Customer SystemId"` linking to a BC customer. Company Locations carry address details and their own `"Customer SystemId"` for per-location BC customer mapping. This two-level structure supports scenarios where a single company has multiple shipping locations, each needing different tax or posting setups. + +Catalog Prices store per-product pricing overrides for B2B catalogs. Payment Terms map Shopify's payment terms to BC's payment terms codes. + +```mermaid +erDiagram + COMPANY ||--o{ COMPANY_LOCATION : has + COMPANY ||--o{ CATALOG : owns + CATALOG ||--o{ CATALOG_PRICE : prices + COMPANY }o--|| SHOP : belongs_to +``` + +The B2B order flow differs from D2C: when an order has a `"Company Id"`, the connector uses the company mapping strategy instead of the customer mapping strategy, and it looks up the company location to determine the bill-to/ship-to customer. + +## Payments and transactions + +Order Transactions record the individual payment events on an order (authorization, capture, refund). They are imported from Shopify and store amounts, gateway names, and status. + +Gift Cards have their own table because they serve dual roles: as a line item when purchased and as a payment instrument when redeemed. + +Payouts represent Shopify's periodic settlements to the merchant's bank account. Disputes track chargeback and inquiry events. + +```mermaid +erDiagram + ORDER_HEADER ||--o{ ORDER_TRANSACTION : paid_by + ORDER_HEADER ||--o{ GIFT_CARD : includes + SHOP ||--o{ PAYOUT : receives + SHOP ||--o{ DISPUTE : has +``` + +## Infrastructure + +Several tables serve as cross-cutting infrastructure used across multiple domains. + +Metafields attach custom key-value data to any owning entity (Product, Variant, Customer, Company). They use a polymorphic pattern: `"Parent Table No."` identifies the owner's table, and `"Owner Id"` identifies the specific record. The `"Owner Type"` enum and `IMetafieldOwnerType` interface abstract over the different owner types. Each metafield has a typed value validated by the `IMetafieldType` interface. Negative IDs indicate BC-created metafields not yet synced to Shopify. + +Tags use the same polymorphic parent pattern (`"Parent Table No."` + `"Parent Id"`) and can attach to Products, Orders, or any other entity that supports them. They are stored as individual records with a 250-tag-per-parent limit enforced in the OnInsert trigger. + +Data Capture stores raw JSON responses from Shopify API calls, linked to the record they were imported for via `"Linked To Table"` and `"Linked To Id"` (using SystemId). This is used for debugging and for extracting additional data that the connector does not explicitly model. + +Log Entries record activity for debugging, with a configurable logging mode on the Shop (None, Errors Only, All). + +Document Links (`Shpfy Doc. Link To Doc.`) create bidirectional associations between Shopify documents (orders, returns, refunds) and BC documents (sales orders, invoices, credit memos). This table uses enum-driven dispatch via `IOpenBCDocument` and `IOpenShopifyDocument` interfaces to open the correct page for any linked document. + +```mermaid +erDiagram + METAFIELD }o--|| PRODUCT : attaches_to + METAFIELD }o--|| VARIANT : attaches_to + METAFIELD }o--|| CUSTOMER : attaches_to + TAG }o--|| PRODUCT : attaches_to + DOCUMENT_LINK }o--|| ORDER_HEADER : links +``` + +## Cross-cutting design decisions + +**SystemId-based linking** is used everywhere that the connector references BC master data. Products link to Items via `"Item SystemId"`, Customers link via `"Customer SystemId"`, and so on. FlowFields provide the display value. This prevents broken references when BC records are renumbered. + +**Cascade deletes in AL triggers** (not database-level foreign keys) ensure referential integrity. Deleting a Product cascades to its Variants and Metafields. Deleting an Order Header cascades to Lines, Returns, Refunds, Fulfillment Orders, Data Captures, and Fulfillments. This is implemented in OnDelete triggers because AL does not support database-level cascading deletes. + +**Dual currency** is pervasive on order-related tables. Shop currency and presentment currency fields exist side by side for all monetary values. The `"Currency Handling"` enum on the Shop determines which set is used when creating BC documents. + +**Polymorphic parent pattern** appears in Tags (`"Parent Table No."` + `"Parent Id"`), Metafields (`"Parent Table No."` + `"Owner Id"`), and Data Capture (`"Linked To Table"` + `"Linked To Id"`). This avoids creating separate tag/metafield tables per entity type. + +**Negative ID convention** for BC-created records appears in Metafields and potentially other tables. When a record is created locally (not yet synced to Shopify), the OnInsert trigger assigns a negative ID by decrementing from the current minimum negative ID (or starting at -1). After Shopify assigns a real ID, the record is updated. + +**Shop-scoped filtering** is applied everywhere. Almost every table has a `"Shop Code"` field and operations filter by it. This is the multi-tenancy mechanism -- a single BC company can connect to multiple Shopify shops without data mixing. diff --git a/src/Apps/W1/Shopify/App/docs/extensibility.md b/src/Apps/W1/Shopify/App/docs/extensibility.md new file mode 100644 index 0000000000..b60fb465ea --- /dev/null +++ b/src/Apps/W1/Shopify/App/docs/extensibility.md @@ -0,0 +1,107 @@ +# Extensibility + +The Shopify Connector exposes two categories of extension points: **integration events** (subscribe to react to or modify behavior) and **interfaces** (implement to provide entirely new strategies). The Shop table's enum fields select which interface implementation is active -- extending the connector often means adding a new enum value with a corresponding interface implementation. + +## Overview + +The extension model follows a consistent pattern across all domains. Configuration enums on the Shop table (like `"Customer Mapping Type"`, `"Stock Calculation"`, `"Status for Created Products"`) each map to an interface. The enum's implementation attribute links each value to a codeunit that implements the interface. At runtime, the connector reads the enum from the Shop record and dispatches through the interface. + +To add a new strategy, you extend the enum (adding a new value), create a codeunit implementing the interface, and link them via the `Implementation` attribute on the enum extension. No modification to existing code is needed. + +Integration events provide finer-grained hooks for modifying data in flight. Most follow the `OnBefore`/`OnAfter` pattern, where `OnBefore` events include a `var Handled: Boolean` parameter that lets you suppress default behavior entirely. + +Note: the `CommunicationEvents` codeunit's events are marked `InternalEvent` (not `IntegrationEvent`), meaning they are only subscribable from within the app itself. They exist for test isolation, not for extension. + +## Customize order creation + +The order processing flow in `ShpfyProcessOrder.Codeunit.al` and `ShpfyOrderMapping.Codeunit.al` fires events at every major step. + +**Sales header creation** -- `OnBeforeCreateSalesHeader` gives you the Shopify Order Header and lets you set `IsHandled` to completely replace header creation logic. `OnAfterCreateSalesHeader` fires after the header is inserted and validated, giving you the Sales Header to modify. + +**Sales line creation** -- `OnBeforeCreateSalesLine` and `OnAfterCreateSalesLine` in `ShpfyProcessOrder.Codeunit.al` let you intercept line creation. This is where you would add custom line types, modify quantities, or insert additional lines. + +**Customer mapping override** -- `OnBeforeMapCustomer` on `ShpfyOrderEvents.Codeunit.al` fires before the customer mapping strategy runs. Set `Handled := true` and populate `"Sell-to Customer No."` on the Order Header to bypass the standard mapping entirely. `OnAfterMapCustomer` lets you adjust the result. + +**Shipment/payment method mapping** -- `OnBeforeMapShipmentMethod`, `OnBeforeMapShipmentAgent`, and `OnBeforeMapPaymentMethod` each allow you to override the standard mapping with `Handled := true`. The corresponding `OnAfter` events let you adjust the mapped values. + +**Post-processing** -- `OnAfterProcessSalesDocument` fires after the complete sales document is created and optionally released, giving you both the Sales Header and the Shopify Order Header. + +## Customize product mapping + +Product events live in `ShpfyProductEvents.Codeunit.al` and cover both import and export directions. + +**Item creation from Shopify** -- `OnAfterCreateItem` fires after a new BC Item is created from an imported Shopify product/variant. You receive the Shop, Product, Variant, and the new Item record. Use this to set additional fields (dimensions, posting groups, etc.) that the template does not cover. `OnAfterCreateItemVariant` is the equivalent for variant-to-item-variant creation. + +**Template selection** -- `OnAfterFindItemTemplate` lets you override which BC Item Template is used for a specific Shopify product. This is how you implement per-product-type or per-vendor template selection. + +**Product body HTML** -- `OnBeforeCreateProductBodyHtml` (with `IsHandled`) and `OnAfterCreateProductBodyHtml` let you customize the HTML description sent to Shopify during export. The `IsHandled` pattern lets you replace the standard extended-text + marketing-text + attributes assembly entirely. + +**Tags** -- `OnAfterGetCommaSeparatedTags` on the Product Events codeunit lets you modify the tag string before it is sent to Shopify. + +**Price calculation** -- Events in `ShpfyProductPriceCalc.Codeunit.al` let you override how prices and compare-at prices are calculated during export. + +**Product status on creation** -- The `"Status for Created Products"` enum uses the `ICreateProductStatusValue` interface. Built-in values are Active and Draft. Extend the enum and implement the interface to add new initial statuses. + +**Action for removed products** -- The `"Action for Removed Products"` enum uses `IRemoveProductAction`. Extend to control what happens to the Shopify product when the linked BC item is blocked or the product is deleted locally. + +## Customize customer and company mapping + +**Customer mapping strategies** -- The `"Customer Mapping Type"` enum dispatches through `ICustomerMapping`. Built-in implementations: + +- `ShpfyCustByEmailPhone.Codeunit.al` -- matches by email, then phone +- `ShpfyCustByBillto.Codeunit.al` -- matches by bill-to address +- `ShpfyCustByDefaultCust.Codeunit.al` -- always returns the Shop's default customer + +To add a new mapping strategy, extend the `"Shpfy Customer Mapping"` enum and implement `ICustomerMapping` with its two `DoMapping` overloads. + +**Company mapping strategies** -- The `"Company Mapping Type"` enum dispatches through `ICompanyMapping`. Built-in implementations match by email/phone, by tax ID, or by default company. + +**Customer name formatting** -- The `"Name Source"` and `"Name 2 Source"` enums use `ICustomerName` to control how the BC customer name is derived from Shopify data (CompanyName, FirstAndLastName, LastAndFirstName, None). + +**County resolution** -- The `"County Source"` enum uses `ICounty` to control whether the county field comes from the province code or name. `ICountyFromJson` handles the JSON-to-county mapping direction. + +**Customer events** -- `ShpfyCustomerEvents.Codeunit.al` provides `OnBeforeCreateCustomer`, `OnAfterCreateCustomer`, `OnBeforeUpdateCustomer`, and `OnAfterUpdateCustomer` events for intercepting customer sync in both directions. + +## Customize stock calculation + +Stock calculation is driven by two interfaces: + +**`IStockAvailable`** -- A simple boolean check: can this type of item have stock? The `"Inventory Management"` enum on Shop Location uses this. Implementations include `ShpfyCanHaveStock.Codeunit.al` (returns true) and `ShpfyCanNotHaveStock.Codeunit.al` (returns false). + +**`IStockCalculation`** (aka `"Shpfy Stock Calculation"`) -- Computes the actual stock quantity for an Item. The Shop Location's `"Stock Calculation"` enum selects the implementation. Built-in options: + +- `ShpfyBalanceToday.Codeunit.al` -- projected available balance +- `ShpfyFreeInventory.Codeunit.al` -- inventory minus reserved +- `ShpfyDisabledValue.Codeunit.al` -- always returns 0 + +To add a new calculation, extend the `"Shpfy Stock Calculation"` enum and implement the `"Shpfy Stock Calculation"` interface. Your `GetStock()` receives an Item record (already filtered to the relevant location) and returns a decimal. + +**`IExtendedStockCalculation`** -- An extended version of the stock calculation interface that receives additional context. If your implementation also implements this interface, the connector will call it instead. + +**Inventory events** -- `ShpfyInventoryEvents.Codeunit.al` provides events for intercepting the stock sync flow. + +## Customize return and refund processing + +The `"Return and Refund Process"` enum dispatches through `IReturnRefundProcess`. The interface has three methods: + +- `IsImportNeededFor()` -- should the connector import data for this source document type? +- `CanCreateSalesDocumentFor()` -- can a BC document be created from this source? +- `CreateSalesDocument()` -- create the BC Sales Credit Memo or Return Order + +The `IDocumentSource` interface controls which Shopify document (return or refund) provides the line items. The `IExtendedDocumentSource` interface extends this with additional context. + +**Refund processing events** -- `ShpfyRefundProcessEvents.Codeunit.al` provides events around credit memo creation for customizing the generated document. + +## Customize metafields + +Metafields are extensible through two interfaces: + +**`IMetafieldType`** -- Validates and provides examples for metafield value types. There are 25+ built-in implementations covering boolean, date, dimension, money, URL, references, etc. To support a new Shopify metafield type, extend the `"Shpfy Metafield Type"` enum and implement this interface. + +**`IMetafieldOwnerType`** -- Maps owner types to table IDs and resolves shop codes. Built-in owners are Product, ProductVariant, Customer, and Company. This rarely needs extension since Shopify's owner types are fixed. + +## Bulk operations + +The `IBulkOperation` interface supports async bulk mutations. Implementations provide the GraphQL mutation template, the JSONL input, and revert logic for failed operations. Built-in implementations handle bulk price updates (`ShpfyBulkUpdateProductPrice.Codeunit.al`) and bulk image updates (`ShpfyBulkUpdateProductImage.Codeunit.al`). + +To add a new bulk operation, extend the `"Shpfy Bulk Operation Type"` enum and implement `IBulkOperation`. Your implementation must handle the revert case because bulk operations are async -- the connector cannot roll back within the same transaction. diff --git a/src/Apps/W1/Shopify/App/docs/patterns.md b/src/Apps/W1/Shopify/App/docs/patterns.md new file mode 100644 index 0000000000..2ef994f6f6 --- /dev/null +++ b/src/Apps/W1/Shopify/App/docs/patterns.md @@ -0,0 +1,167 @@ +# Patterns + +This document covers the recurring code patterns in the Shopify Connector, with concrete examples from the codebase. The last section covers legacy patterns that are being phased out. + +## Hash-based change detection + +The connector uses a custom hash algorithm (`ShpfyHash` codeunit in `src/Helpers/`) to detect changes in product data before making API calls. The Product table stores three hash fields: `"Image Hash"`, `"Tags Hash"`, and `"Description Html Hash"`. The Variant table stores its own `"Image Hash"`. + +The pattern works like this: when a product is imported or last exported, the hash is computed and stored. On the next export, the connector recomputes the hash from the current BC data and compares it to the stored value. Only if the hashes differ does it make the API call. + +In `ShpfyProduct.Table.al`, the `SetDescriptionHtml()` method shows this clearly -- after writing the HTML to the blob field, it immediately computes and stores the hash: + +``` +"Description Html Hash" := Hash.CalcHash(NewDescriptionHtml); +``` + +The `CalcTagsHash()` method on the same table computes the hash from the comma-separated tag string. This means adding, removing, or reordering tags will change the hash and trigger an update. + +This pattern is essential for performance. A store with thousands of products would exhaust Shopify's GraphQL rate limit within minutes if every product triggered an API call on every sync. The hash comparison keeps the actual API calls proportional to the number of changes. + +## GraphQL query builder pattern + +All 145 codeunits in `src/GraphQL/Codeunits/` implement the `IGraphQL` interface, which requires two methods: + +- `GetGraphQL()` -- returns the GraphQL query text with parameter placeholders +- `GetExpectedCost()` -- returns the estimated query cost for rate limiting + +The `ShpfyGraphQLQueries.Codeunit.al` acts as the dispatcher. It receives a `"Shpfy GraphQL Type"` enum value and a `Dictionary of [Text, Text]` of parameters, looks up the corresponding `IGraphQL` implementation, calls `GetGraphQL()`, substitutes parameters into the query string, and returns the final query along with the expected cost. + +The `ShpfyCommunicationMgt.Codeunit.al` is the single entry point for all API calls. Its `ExecuteGraphQL()` overloads accept either a `"Shpfy GraphQL Type"` (type-safe) or a raw query string (for ad-hoc queries). Before executing, it calls `WaitForRequestAvailable()` on the rate limiter with the expected cost. + +The naming convention for GraphQL codeunits follows a pattern: `ShpfyGQL` + verb/noun describing the query. For example, `ShpfyGQLCustomerIds` fetches customer IDs, `ShpfyGQLNextCustomerIds` fetches the next page, `ShpfyGQLFindCustByEMail` searches by email. The `Next*` prefix indicates a pagination continuation query. + +## SystemId-based linking + +The connector consistently uses GUIDs (SystemId) rather than Code/No. to link Shopify records to BC master data. This is unusual in the BC ecosystem where most integrations use the business key. + +The pattern appears on Product (`"Item SystemId"`), Variant (`"Item SystemId"` + `"Item Variant SystemId"`), Customer (`"Customer SystemId"`), and Company (`"Customer SystemId"`). In each case, a FlowField provides the human-readable value via CalcFormula: + +``` +field(103; "Item No."; Code[20]) +{ + CalcFormula = lookup(Item."No." where(SystemId = field("Item SystemId"))); + FieldClass = FlowField; +} +``` + +This design has a concrete advantage: if a merchant renumbers items (changes item numbers), the Shopify links survive because the SystemId is immutable. The tradeoff is that you cannot look up the Shopify product for an item using the item number directly -- you must use the SystemId. + +The FlowField pattern means `"Item No."` is not stored in the database -- it is computed on demand. Code that needs the item number must call `CalcFields("Item No.")` first, which is a common gotcha. + +## Rate limiting + +The `ShpfyGraphQLRateLimit.Codeunit.al` is a SingleInstance codeunit that tracks Shopify's cost-based rate limit. It is critical infrastructure because Shopify throttles based on query cost, not request count. + +After each API call, `SetQueryCost()` reads the `restoreRate` and `currentlyAvailable` values from the response's throttle status. Before each call, `WaitForRequestAvailable(ExpectedCost)` checks whether enough budget is available. If not, it computes the sleep duration: + +``` +WaitTime := (Max(ExpectedCost - LastAvailable, 0) / RestoreRate * 1000) - (CurrentDateTime - LastRequestedOn) +``` + +This formula accounts for the time elapsed since the last request (during which budget has been restoring at `RestoreRate` points per second). If `RestoreRate` is zero (no data yet), it defaults to 50 -- Shopify's standard restore rate. + +The `GoToSleep()` method uses AL's `Sleep()` function with a TryFunction wrapper. If the calculated sleep time causes an error (e.g., negative duration), it falls back to a 100ms sleep. + +The API version is hardcoded as `'2026-01'` in `ShpfyCommunicationMgt.Codeunit.al`. The connector validates the API version expiry date and shows warnings/errors when it is approaching or has passed expiry, pulling the date from Azure Key Vault. + +## Webhook multi-company fan-out + +Shopify webhooks are registered per-shop, but a single Shopify shop can be connected to multiple BC companies. The `ShpfyWebhookNotification.Codeunit.al` handles this fan-out. + +When a webhook fires, the notification arrives with a subscription ID that encodes the shop domain. The codeunit reconstructs the Shopify URL from this ID, then queries the Shop table filtered by `Enabled = true` and matching URL. Because multiple BC companies might have an enabled Shop record pointing to the same Shopify store, the `FindSet()` loop processes the notification once per matching shop: + +``` +if Shop.FindSet() then + repeat + case WebhookNotification."Resource Type Name" of + Format("Shpfy Webhook Topic"::ORDERS_CREATE): + ... + Format("Shpfy Webhook Topic"::BULK_OPERATIONS_FINISH): + ... + end; + until Shop.Next() = 0; +``` + +Each iteration processes the notification in the context of that shop's BC company, with a `Commit()` after each to isolate failures. + +## Bulk operations framework + +For operations that would require many individual API calls (like updating prices across thousands of variants), the connector uses Shopify's bulk mutation API. The framework lives in `src/Bulk Operations/`. + +The flow is: + +1. `ShpfyBulkOperationMgt.Codeunit.al` collects the input data as JSONL lines +2. It calls `bulkOperationRunMutation` via GraphQL, passing the mutation template and JSONL input +3. Shopify processes the mutation asynchronously and fires a `BULK_OPERATIONS_FINISH` webhook when done +4. The webhook handler calls back into the `IBulkOperation` implementation to process results or revert failures + +The `IBulkOperation` interface requires `RevertFailedRequests()` and `RevertAllRequests()` methods because the bulk operation is async -- by the time results arrive, the original transaction is long committed. If the bulk operation fails entirely, all changes must be reverted. If it partially succeeds, only the failed entries are reverted. + +The `ShpfyBulkOperation.Table.al` tracks the state of each bulk operation: type, status (Created, Running, Completed, Failed), shop code, and the request/response data. + +## Shop-scoped multi-tenancy + +Every data table in the connector includes a `"Shop Code"` field, and every operation filters by it. This is the multi-tenancy boundary -- a single BC company can connect to multiple Shopify shops (e.g., different storefronts or brands) and the data never mixes. + +The pattern is consistent: codeunits that operate on shop data accept a Shop record or Shop Code and use it to filter all queries. The `SetShop()` pattern appears across API codeunits -- it sets the shop context once, and all subsequent operations use it. + +This design means you cannot query "all products across all shops" without explicitly iterating shops. It also means that the same BC item can be linked to different Shopify products in different shops. + +## Timestamp-based incremental sync + +The Synchronization Info table (`ShpfySynchronizationInfo.Table.al`) stores one timestamp per shop-code + sync-type pair. The empty-date sentinel is `2004-01-01T00:00:00` (not `0DT`), chosen to be old enough to import all data but avoid zero-date edge cases. + +The pattern in the Shop table: + +``` +GetLastSyncTime(Type) -- reads the stored timestamp, returns sentinel if none +SetLastSyncTime(Type) -- stores CurrentDateTime after successful sync +``` + +For order sync specifically, the key uses the Shop's integer hash (`"Shop Id"`) instead of the Shop Code. This is because multiple BC companies connected to the same Shopify store should share the order sync cursor -- without this, each company would re-import all orders. + +Sync timestamps are passed to Shopify's `updated_at` filter in API queries. This means the sync is truly incremental -- only records changed since the last sync are fetched. The risk is that if a sync fails partway through, the timestamp may not be updated, causing the next sync to re-process some records. The connector handles this gracefully because most operations are idempotent (updating a local record with the same data is a no-op). + +## Interface-driven strategy selection + +The connector's most distinctive pattern is using AL enum + interface to implement the strategy pattern. The flow is: + +1. An enum type is defined with an interface implementation attribute +2. Each enum value maps to a codeunit implementing the interface +3. The Shop table has a field of that enum type +4. At runtime, the code reads the enum value and dispatches through the interface + +Example with customer mapping: + +``` +// Enum declaration links values to implementations +enum 30105 "Shpfy Customer Mapping" implements "Shpfy ICustomer Mapping" +{ + value(0; "By Email/Phone") { Implementation = "Shpfy ICustomer Mapping" = "Shpfy Cust. By Email/Phone"; } + value(1; "By Bill-to Info") { Implementation = "Shpfy ICustomer Mapping" = "Shpfy Cust. By Bill-to"; } + value(2; "By Default Customer") { Implementation = "Shpfy ICustomer Mapping" = "Shpfy Cust. By Default Cust."; } +} + +// Usage at runtime +ICustomerMapping := Shop."Customer Mapping Type"; // enum-to-interface dispatch +CustomerNo := ICustomerMapping.DoMapping(...); +``` + +This pattern repeats for stock calculation, product status, removal actions, customer name formatting, county resolution, company mapping, return/refund processing, metafield types, metafield owners, bulk operations, and document link handlers. Once you understand one instance, you understand them all. + +## Legacy patterns + +### Config Template Header (removed in v25) + +Before v25, the connector used BC's Config. Template Header system for item and customer creation templates. Fields like `"Item Template Code"` and `"Customer Template Code"` had `TableRelation` to Config. Template Header and are now removed with `ObsoleteState = Removed; ObsoleteTag = '25.0'`. The replacement is the `"Item Templ. Code"` and `"Customer Templ. Code"` fields that reference the newer Item Templ. and Customer Templ. tables. + +If you see `#if not CLEANSCHEMA25` guards in the source, these protect the removed fields until the schema cleanup version. Code should never reference these fields. + +### Obsolete REST API fields + +Several fields on Order Header that existed for the REST API era have been removed: Token, Cart Token, Checkout Token, Reference, Session Hash, Contact Email, and Buyer Accepts Marketing. These carried `ObsoleteReason = 'Not available in GraphQL data.'` and were removed in v25. The connector is fully committed to GraphQL. + +### Obsolete Tax Code on Variant + +The `"Tax Code"` field on Shpfy Variant was deprecated in v28 because Shopify's API version 2025-10 removed `taxCode` from ProductVariant. This field is pending removal with `ObsoleteTag = '28.0'`. diff --git a/src/Apps/W1/Shopify/App/src/Base/docs/CLAUDE.md b/src/Apps/W1/Shopify/App/src/Base/docs/CLAUDE.md new file mode 100644 index 0000000000..b069f75922 --- /dev/null +++ b/src/Apps/W1/Shopify/App/src/Base/docs/CLAUDE.md @@ -0,0 +1,36 @@ +# Base + +Core infrastructure: shop configuration, sync tracking, API communication, +role center integration, installation, and background sync orchestration. + +## How it works + +`Shpfy Shop` (`Tables/ShpfyShop.Table.al`) is the central configuration +object with 100+ fields controlling sync directions, mapping strategies, +G/L account mappings, B2B flags, webhook settings, and more. Multiple +shops per BC company, each identified by a `Code` primary key. `Shop Id` +is an integer hash of the Shopify URL for efficient lookups. + +`Shpfy Synchronization Info` tracks incremental sync cursors, keyed by +shop code and sync type. Order sync specifically keys by `Shop Id` hash +so multiple BC companies sharing a Shopify shop share the cursor. + +`Shpfy Communication Mgt.` is the single API entry point, constructing +versioned URLs (currently `2026-01`), handling auth, and dispatching +GraphQL queries. `Shpfy Communication Events` publishes internal events +for every API interaction (`OnClientSend`, `OnClientPost`, `OnClientGet`, +`OnGetContent`, `OnGetAccessToken`) -- tests use these to mock responses. + +`Shpfy Background Syncs` orchestrates all sync operations via Job Queue, +splitting between background-allowed and foreground-only shops. + +`Shpfy Installer` sets up retention policies and Cue thresholds on +install, and disables all shops on company copy or environment cleanup. + +## Things to know + +- The `Shpfy Cue` table uses FlowFields for role center counts: unmapped + customers/products/companies, unprocessed orders/shipments, sync errors. +- Empty sync time sentinel is `2004-01-01` (`GetEmptySyncTime()`), not `0DT`. +- Three page extensions embed Shopify Activities into standard role centers. +- `ShpfyConnectorGuide` and `ShpfyInitialImport` provide first-time setup. diff --git a/src/Apps/W1/Shopify/App/src/Bulk Operations/docs/CLAUDE.md b/src/Apps/W1/Shopify/App/src/Bulk Operations/docs/CLAUDE.md new file mode 100644 index 0000000000..ad14830a4c --- /dev/null +++ b/src/Apps/W1/Shopify/App/src/Bulk Operations/docs/CLAUDE.md @@ -0,0 +1,19 @@ +# Bulk operations + +Provides an async GraphQL bulk mutation framework for high-volume updates to Shopify. Instead of sending hundreds of individual API calls, this module uploads a JSONL file and submits a single `bulkOperationRunMutation` to process it server-side. + +## How it works + +The flow is: build JSONL input, upload it to a staged URL, submit the bulk mutation referencing that URL, then wait for a webhook callback. `ShpfyBulkOperationMgt.SendBulkMutation` orchestrates this -- it checks that no bulk operation of the same type is already running, calls `ShpfyBulkOperationAPI.CreateBulkOperationMutation` (which creates the upload URL, POSTs the JSONL as multipart/form-data, then sends the GraphQL mutation), and records the operation in the `Shpfy Bulk Operation` table. When Shopify completes the operation, it fires a webhook that calls `ProcessBulkOperationNotification`, which fetches the final status, result URL, and any error codes. + +Each concrete operation implements the `Shpfy IBulk Operation` interface, which provides the GraphQL mutation template, JSONL input template, and revert logic. The `OnModify` trigger on the `Shpfy Bulk Operation` table automatically calls `RevertFailedRequests` on completion or `RevertAllRequests` on cancellation/failure -- this restores local records that were optimistically updated before the bulk call. + +Two implementations exist today: `ShpfyBulkUpdateProductImage` (updates product media) and `ShpfyBulkUpdateProductPrice` (updates variant prices and costs). The threshold for switching from individual calls to bulk is 100 items. + +## Things to know + +- Only one bulk operation per type (mutation/query) can run at a time per shop. The module polls the current operation status before starting a new one. +- Enabling bulk operations requires a BC-licensed user and registers a webhook subscription on the shop. +- The `Shpfy Bulk Operation` table stores request data as a JSONL blob so revert logic can compare against Shopify's JSONL result to identify which items succeeded vs. failed. +- `RevertFailedRequests` for price updates parses the JSONL result line by line, collects successful variant IDs, and reverts only the records that were not in the success list. +- Adding a new bulk operation type means implementing `Shpfy IBulk Operation` and adding an enum value to `Shpfy Bulk Operation Type`. diff --git a/src/Apps/W1/Shopify/App/src/Catalogs/docs/CLAUDE.md b/src/Apps/W1/Shopify/App/src/Catalogs/docs/CLAUDE.md new file mode 100644 index 0000000000..a6771819b0 --- /dev/null +++ b/src/Apps/W1/Shopify/App/src/Catalogs/docs/CLAUDE.md @@ -0,0 +1,20 @@ +# Catalogs + +Manages Shopify catalogs -- both B2B company catalogs (per-company price lists) and market catalogs (per-market pricing). Each catalog has its own price list in Shopify, and this module syncs BC-calculated prices into those price lists. + +## How it works + +There are two catalog types: Company and Market. Company catalogs are tied to a Shopify Company via `Company SystemId` and are created automatically when setting up B2B companies. Market catalogs are linked to Shopify markets through the `Shpfy Market Catalog Relation` table. The `ShpfyCatalogAPI` codeunit handles CRUD operations -- creating catalogs with associated publications and price lists, and importing existing catalogs from Shopify. + +Price sync is driven by `ShpfySyncCatalogPrices`. For each catalog with `Sync Prices` enabled, it fetches current Shopify prices via the catalog's price list, then recalculates prices using BC's `Shpfy Product Price Calc.` with the catalog's Customer Price Group, Customer Discount Group, and posting groups. Changed prices are pushed back in batches of 250 via the `UpdateCatalogPrices` GraphQL mutation. The `Shpfy Catalog Price` table is temporary -- used only during sync to compare current vs. calculated values. + +The `Shpfy Catalog` table carries full pricing context: Customer Price Group, Customer Discount Group, Gen. Bus. Posting Group, VAT settings, and an optional Customer No. that overrides catalog-level settings with the customer's own price/discount groups. + +## Things to know + +- `Shpfy Catalog Price` is a temporary table -- it holds the Shopify-side prices just long enough to diff against BC calculations during sync. +- If a catalog's currency code does not match what Shopify reports, the sync is skipped and a skipped record is logged. +- Company catalogs get their own publication and price list created automatically via separate GraphQL calls. +- Market catalogs track which Shopify markets they serve through `Shpfy Market Catalog Relation`, which is refreshed on every import. +- Price updates are batched at 250 variants per GraphQL call to stay within Shopify limits. +- The catalog URL construction differs between unified and non-unified Shopify markets. diff --git a/src/Apps/W1/Shopify/App/src/Companies/docs/CLAUDE.md b/src/Apps/W1/Shopify/App/src/Companies/docs/CLAUDE.md new file mode 100644 index 0000000000..9b6e66294d --- /dev/null +++ b/src/Apps/W1/Shopify/App/src/Companies/docs/CLAUDE.md @@ -0,0 +1,44 @@ +# Companies + +B2B company management -- separate from D2C customer sync. + +## What it does + +Imports Shopify companies (a Shopify Plus feature) and maps them to BC Customers. +Each company has locations with billing addresses, tax registration IDs, and +payment terms. The company's main contact is a Shopify Customer used for email +and phone-based matching. + +## How it works + +`ShpfyCompanyImport` retrieves the company and its main contact from the API, +updates locations, then delegates to `ShpfyCompanyMapping.FindMapping`. The +mapping resolves through the `ICompanyMapping` interface, selected by the Shop's +"Company Mapping Type" enum. If no match is found and auto-create is on, +`ShpfyCreateCustomer.CreateCustomerFromCompany` builds a BC Customer from the +company's location data (address, phone, tax ID, payment terms). + +## Things to know + +- Requires Shopify Plus. Without it, the companies API is unavailable. +- The relationship chain is Company -> Location -> Customer. A company has + locations (physical addresses), and each company has a main contact who is a + Shopify Customer record. The `Main Contact Customer Id` on the Company + links to `Shpfy Customer`. +- `Customer SystemId` on Company links to the BC Customer, same pattern as + Customers and Products. +- Company Locations can override the default customer mapping for order + processing via `Sell-to Customer No.` and `Bill-to Customer No.` fields. +- Three mapping strategies exist: By Email/Phone (matches main contact's + email/phone to BC Customer), By Tax Id (matches location's tax registration + ID to BC Customer via the `Tax Registration Id Mapping` interface), and + Default Company (always returns a configured default). +- `IFindCompanyMapping` extends `ICompanyMapping` with a `FindMapping` method. + The mapping codeunit checks at runtime whether the selected implementation + supports `IFindCompanyMapping` and falls back to `CompByEmailPhone` if not. +- Tax registration ID mapping is itself pluggable via + `Shpfy Tax Registration Id Mapping` interface with two implementations: + `ShpfyTaxRegistrationNo` (matches Registration No.) and + `ShpfyVATRegistrationNo` (matches VAT Registration No.). +- When creating customers from companies, the county is resolved through the + same `ICounty` interface used by the Customers module. diff --git a/src/Apps/W1/Shopify/App/src/Companies/docs/extensibility.md b/src/Apps/W1/Shopify/App/src/Companies/docs/extensibility.md new file mode 100644 index 0000000000..c6d4682bec --- /dev/null +++ b/src/Apps/W1/Shopify/App/src/Companies/docs/extensibility.md @@ -0,0 +1,54 @@ +# Companies extensibility + +## Interfaces + +**ICompanyMapping** -- the strategy interface for resolving a Shopify company +to a BC Customer. Selected via the extensible `Shpfy Company Mapping` enum +(value 0 = By Email/Phone, 2 = Default Company, 3 = By Tax Id). The +`DoMapping` procedure receives CompanyId, ShopCode, TemplateCode, and +AllowCreate, and returns a Customer No. + +To add a custom B2B company resolution strategy, extend the enum and implement +`ICompanyMapping`. If your strategy also supports the Shopify-to-BC import +direction (not just order-time mapping), implement `IFindCompanyMapping` as +well. + +**IFindCompanyMapping** -- extends `ICompanyMapping` with `FindMapping`, which +receives the Shpfy Company record and a temporary Shpfy Customer record (the +company's main contact). The mapping codeunit (`ShpfyCompanyMapping`) +runtime-checks whether the active implementation supports this interface. If +it does not, it falls back to `ShpfyCompByEmailPhone.FindMapping`. This means +custom mapping implementations that only implement `ICompanyMapping` will get +email/phone matching for import and their custom logic for order-time +resolution. + +**Shpfy Tax Registration Id Mapping** -- controls how tax registration IDs +are matched and updated on BC Customers. Three methods: + +- `GetTaxRegistrationId` -- returns the tax ID from a BC Customer +- `SetMappingFiltersForCustomers` -- sets filters on the Customer table to + find a customer by tax ID from a Company Location +- `UpdateTaxRegistrationId` -- writes a new tax ID to a BC Customer during + company creation + +Two implementations: `ShpfyTaxRegistrationNo` (uses Customer."Registration No." +from the Registration No. extension) and `ShpfyVATRegistrationNo` (uses +Customer."VAT Registration No."). Selected via the "Shpfy Comp. Tax Id Mapping" +enum on the Shop. + +## Customizing company import + +Company import reuses the customer events infrastructure. When a company is +auto-created, `ShpfyCreateCustomer.CreateCustomerFromCompany` is called +directly (not through the `OnRun` trigger), so `OnBeforeCreateCustomer` and +`OnAfterCreateCustomer` do not fire for company-created customers. To +customize company-to-customer creation, implement a custom +`IFindCompanyMapping` that intercepts before creation, or subscribe to +`OnBeforeFindCustomerTemplate` to control the template selection. + +## Customizing company export + +`ShpfyCompanyExport` pushes BC Customer data to existing Shopify companies. +There are no company-specific events -- the export uses the same field-diff +pattern as customer export. Customization is through the interfaces above +or by modifying the company/location data after API retrieval. diff --git a/src/Apps/W1/Shopify/App/src/Customers/docs/CLAUDE.md b/src/Apps/W1/Shopify/App/src/Customers/docs/CLAUDE.md new file mode 100644 index 0000000000..08bf9e4434 --- /dev/null +++ b/src/Apps/W1/Shopify/App/src/Customers/docs/CLAUDE.md @@ -0,0 +1,45 @@ +# Customers + +Bi-directional D2C customer sync between BC Customers and Shopify Customers. + +## What it does + +Import pulls Shopify customers, maps them to BC Customers using a configurable +strategy, and optionally auto-creates new customers via templates. Export pushes +BC Customer changes (name, address, email, phone) to Shopify, creating or +updating Shopify customer records. + +## How it works + +The Shop's "Customer Mapping Type" selects a strategy via the `ICustomerMapping` +interface. Three built-in options exist: By Email/Phone (matches on email then +phone), By Bill-to Info (matches on name/address fields), and Default Customer +(always returns a single configured customer). The mapping codeunit +(`ShpfyCustomerMapping`) has its own `DoFindMapping` that tries email then +phone filter matching for the ShopifyToBC direction. + +Customer name formatting is pluggable through `ICustomerName` -- implementations +produce the BC customer Name field from Shopify first/last/company name in +different orderings (FirstLast, LastFirst, CompanyName, Empty). County resolution +uses `ICounty` (for records) and `ICountyFromJson` (for API responses) to +convert between province codes and names. + +## Things to know + +- `Customer SystemId` on `Shpfy Customer` links to the BC Customer, same + pattern as Products. The FlowField `Customer No.` resolves it. +- `Shop Id` is a hash-based identifier used to scope customer queries to a + specific shop. Indexed for fast lookups. `CustomerAPI.FillInMissingShopIds` + backfills this for older records. +- Customer creation uses country-specific templates. `ShpfyCreateCustomer` + looks up the `Shpfy Customer Template` table by Shop Code + Country Code, + falling back to the Shop's default template. +- Export (`ShpfyCustomerExport`) splits the BC Customer name back into + first/last using the Shop's "Name Source" config. It handles multi-email + fields by taking the first email before any semicolon or comma. +- Phone number matching strips all non-digit characters and builds a wildcard + filter (`*1*2*3*...`) to handle format differences. +- The `Shpfy Customer Address` table stores Shopify addresses. Addresses + created from BC get negative IDs (not from Shopify API). +- County export validates province code length and errors if it exceeds the + Shopify field limit. diff --git a/src/Apps/W1/Shopify/App/src/Customers/docs/business-logic.md b/src/Apps/W1/Shopify/App/src/Customers/docs/business-logic.md new file mode 100644 index 0000000000..4900f0d373 --- /dev/null +++ b/src/Apps/W1/Shopify/App/src/Customers/docs/business-logic.md @@ -0,0 +1,90 @@ +# Customers business logic + +## Import flow + +`ShpfySyncCustomers` discovers customer IDs from the Shopify API, then +`ShpfyCustomerImport` processes each one. + +```mermaid +flowchart TD + A[Retrieve Shopify Customer] --> B[CustomerMapping.FindMapping] + B --> C{Mapped?} + C -- yes --> D{Shopify Can Update Customer?} + D -- yes --> E[ShpfyUpdateCustomer] + D -- no --> F[Done] + C -- no --> G{Auto Create Unknown Customers?} + G -- yes --> H[Find default address] + H --> I[ShpfyCreateCustomer] + G -- no --> J[Skip] +``` + +`FindMapping` in `ShpfyCustomerMapping` first checks whether the Shopify Customer +already has a `Customer SystemId` that resolves to a valid BC Customer. If the +linked customer was deleted, it clears the stale SystemId and proceeds with +discovery. The `OnBeforeFindMapping` event fires here, allowing full override. + +The default discovery in `DoFindMapping` tries email first (case-insensitive +filter on BC Customer."E-Mail"), then phone. Phone matching uses +`CreatePhoneFilter`, which strips non-digits, trims leading zeros, and builds a +wildcard pattern like `*4*1*5*5*5*1*2*3*4` so formatting differences (spaces, +dashes, country codes) do not prevent matches. + +Customer creation delegates to `ShpfyCreateCustomer`, which finds a template +using the address's country code. The `Shpfy Customer Template` table maps +(Shop Code, Country Code) to a Customer Template Code. When no country-specific +template exists, a new empty row is inserted (for future configuration) and the +Shop's default template is used. The `OnBeforeFindCustomerTemplate` event can +override this entirely. + +## Export flow + +`ShpfyCustomerExport` iterates BC Customers matching the report filters. + +```mermaid +flowchart TD + A[For each BC Customer] --> B{Create mode?} + B -- yes --> C[CustomerMapping.FindMapping BCToShopify] + C --> D{Found in Shopify?} + D -- no --> E[CreateShopifyCustomer] + D -- yes, same SystemId --> F{Can Update?} + D -- yes, different SystemId --> G[Log skipped - duplicate] + F -- yes --> H[UpdateShopifyCustomer] + B -- no --> I[Find by Customer SystemId + Shop Id] + I --> J{Found?} + J -- yes --> H + J -- no --> K[Skip] +``` + +`FillInShopifyCustomerData` translates BC Customer fields into Shopify Customer +and Customer Address records. Name splitting respects three configurable sources +(Name, Name 2, Contact) and each source can be parsed as FirstAndLastName, +LastAndFirstName, or CompanyName. County mapping uses the `Shpfy Tax Area` table +to resolve between province codes and names based on the Shop's "County Source" +setting (Code or Name). + +The export uses a diff check (`HasDiff`) comparing every field of the customer +and address records before and after filling. Only when something changed does +it call the API. This is the same RecordRef-based field comparison pattern used +in Products. + +## County resolution + +Two interface pairs handle province/county conversion. + +`ICounty` converts from a stored Shopify Customer Address or Company Location +record to a BC County string. Implementations: `ShpfyCountyCode` (returns +province code) and `ShpfyCountyName` (returns province name). + +`ICountyFromJson` converts from a raw JSON address object during API response +parsing. Implementations: `ShpfyCountyFromJsonCode` and +`ShpfyCountyFromJsonName`. + +## Customer name formatting + +`ICustomerName` takes first name, last name, and company name and produces the +BC Customer Name. Implementations: + +- `ShpfyNameisFirstLastName` -- "First Last" +- `ShpfyNameisLastFirstName` -- "Last First" +- `ShpfyNameisCompanyName` -- uses company name +- `ShpfyNameisEmpty` -- returns empty (for cases where name is set elsewhere) diff --git a/src/Apps/W1/Shopify/App/src/Customers/docs/extensibility.md b/src/Apps/W1/Shopify/App/src/Customers/docs/extensibility.md new file mode 100644 index 0000000000..a2d9b0d570 --- /dev/null +++ b/src/Apps/W1/Shopify/App/src/Customers/docs/extensibility.md @@ -0,0 +1,60 @@ +# Customers extensibility + +Events are defined in `ShpfyCustomerEvents` (codeunit 30115). The four +interfaces handle mapping strategy, name formatting, and county resolution. + +## Customizing customer creation + +- `OnBeforeCreateCustomer` -- set Handled to replace the entire customer + creation flow. Receives Shop, Customer Address, and the Customer record to + populate. +- `OnAfterCreateCustomer` -- post-creation hook for setting custom fields, + dimensions, or triggering downstream processes. +- `OnBeforeFindCustomerTemplate` / `OnAfterFindCustomerTemplate` -- override + which Customer Template is selected for a given country code. OnBefore can + set Handled to bypass the default template lookup. + +## Customizing customer mapping + +- `OnBeforeFindMapping` -- fires before the default email/phone matching in + `ShpfyCustomerMapping.FindMapping`. Set Handled and populate the Customer + record to completely bypass built-in logic. Works for both ShopifyToBC and + BCToShopify directions (check the Direction parameter). +- `OnAfterFindMapping` -- fires after a successful match, allowing you to + adjust the mapping or update related records. + +## Customizing customer updates + +- `OnBeforeUpdateCustomer` / `OnAfterUpdateCustomer` -- fire around the + Shopify-to-BC customer update flow. OnBefore can set Handled. +- `OnBeforeSendCreateShopifyCustomer` / `OnBeforeSendUpdateShopifyCustomer` -- + last chance to modify the Shopify Customer and Address records before they + are serialized and sent to the API. + +## Interfaces + +**ICustomerMapping** -- the strategy interface for order-time customer +resolution. Three built-in implementations selected via the extensible +`Shpfy Customer Mapping` enum: + +- `ShpfyCustByEmailPhone` -- matches on email then phone +- `ShpfyCustByBillto` -- matches on bill-to name and address fields +- `ShpfyCustByDefaultCust` -- always returns the Shop's default customer + +To add a custom strategy, extend the enum with a new value and implement +`ICustomerMapping`. The `DoMapping` procedure receives a JSON object with +Name, Name2, Address, PostCode, City, County, CountryCode fields. + +**ICustomerName** -- controls how the BC Customer Name field is composed +from Shopify first/last/company name. Implementations: +`ShpfyNameisFirstLastName`, `ShpfyNameisLastFirstName`, +`ShpfyNameisCompanyName`, `ShpfyNameisEmpty`. Selected via the "Name Source" +enum on the Shop. + +**ICounty** -- converts a stored Shopify address record's province data to a +BC County string. Two implementations: `ShpfyCountyCode` (province code) and +`ShpfyCountyName` (province name). Selected via the "County Source" enum. + +**ICountyFromJson** -- same as ICounty but operates on a raw JSON address +object during API response parsing. Implementations: +`ShpfyCountyFromJsonCode` and `ShpfyCountyFromJsonName`. diff --git a/src/Apps/W1/Shopify/App/src/Document Links/docs/CLAUDE.md b/src/Apps/W1/Shopify/App/src/Document Links/docs/CLAUDE.md new file mode 100644 index 0000000000..d2e4908a53 --- /dev/null +++ b/src/Apps/W1/Shopify/App/src/Document Links/docs/CLAUDE.md @@ -0,0 +1,20 @@ +# Document Links + +Maintains cross-references between Shopify documents (orders, returns, refunds) and Business Central documents (sales orders, invoices, credit memos, shipments, return receipts). + +## How it works + +The central table `Shpfy Doc. Link To Doc.` (table 30146) has a composite key of `(Shopify Document Type, Shopify Document Id, Document Type, Document No.)`. The Shopify side uses the `Shpfy Shop Document Type` enum (Order, Return, Refund), and the BC side uses the `Shpfy Document Type` enum (Sales Order, Sales Invoice, Sales Return Order, Sales Credit Memo, Posted Sales Shipment, Posted Return Receipt, Posted Sales Invoice, Posted Sales Credit Memo). This composite key allows multiple BC documents per Shopify document, which is the normal case: a single Shopify order produces a sales order, then a posted shipment, then a posted invoice. + +Navigation is interface-driven. Both enums carry interface implementations. `Shpfy Shop Document Type` implements `IOpenShopifyDocument` with codeunits like `ShpfyOpenOrder`, `ShpfyOpenReturn`, `ShpfyOpenRefund`. `Shpfy Document Type` implements `IOpenBCDocument` with codeunits like `ShpfyOpenSalesOrder`, `ShpfyOpenPostedSalesInvoice`, and so on. The table's `OpenShopifyDocument()` and `OpenBCDocument()` procedures dispatch to the correct codeunit through the enum's interface implementation. + +`ShpfyDocumentLinkMgt` (codeunit 30262) subscribes to Sales Header deletion, Sales-Post events, and PostSales-Delete to automatically maintain the links. When a sales order is posted, it creates links to the resulting posted shipment, posted invoice, return receipt, and/or posted credit memo. When a sales document is deleted after posting, it transfers the Shopify link to the posted documents. It also handles the invoice-from-shipment scenario by tracing invoice lines back to their shipment numbers. + +`ShpfyBCDocumentTypeConvert` (codeunit 30259) converts between BC record types / Sales Document Type enum and the `Shpfy Document Type` enum, and can also convert back. This is used everywhere a link is created. + +## Things to know + +- The `Linked To Documents` page (page 30148) is a ListPart designed to be embedded as a factbox. Drilling down on `Document No.` calls `OpenBCDocument()`, which dispatches through the interface. +- Both enums are `Extensible = true`, so third-party apps can add new Shopify document types or BC document types with their own navigation codeunits. +- The link records are created by the modules that create the BC documents (`ShpfyProcessOrder`, `ShpfyCreateSalesDocRefund`), not by this module. This module is responsible for lifecycle management (propagating links when documents are posted or deleted) and navigation. +- `Order Header.IsProcessed()` checks `Doc. Link To Doc.` in addition to the `Processed` flag, so the link table is load-bearing for import conflict detection. diff --git a/src/Apps/W1/Shopify/App/src/GraphQL/docs/CLAUDE.md b/src/Apps/W1/Shopify/App/src/GraphQL/docs/CLAUDE.md new file mode 100644 index 0000000000..4d945b85fb --- /dev/null +++ b/src/Apps/W1/Shopify/App/src/GraphQL/docs/CLAUDE.md @@ -0,0 +1,35 @@ +# GraphQL + +Type-safe query infrastructure for the Shopify Admin API. Every API call +the connector makes passes through this layer. + +## How it works + +`Shpfy IGraphQL` (`Interfaces/ShpfyIGraphQL.Interface.al`) defines two +methods: `GetGraphQL()` returns a JSON-encoded GraphQL request body, and +`GetExpectedCost()` returns cost points for rate limiting. Each API +operation is a separate codeunit implementing this interface. The enum +`Shpfy GraphQL Type` (`Enums/ShpfyGraphQLType.Enum.al`) maps ~143 values +to their implementing codeunits via AL's `implements` keyword. + +Parameters use named `{{placeholder}}` tokens. The dispatcher +`Shpfy GraphQL Queries` (`Codeunits/ShpfyGraphQLQueries.Codeunit.al`) +accepts a `Dictionary of [Text, Text]` and does literal string +replacement -- no escaping, no StrSubstNo. Callers format values +correctly within the query template itself. + +Rate limiting is handled by `Shpfy GraphQL Rate Limit`, a SingleInstance +codeunit that tracks Shopify's `restoreRate` and `currentlyAvailable` +from throttle status and sleeps before requests that would exceed budget. + +## Things to know + +- 143 query-builder codeunits, each unique -- not copy-paste templates. + Plus `ShpfyGraphQLQueries` (dispatcher) and `ShpfyGraphQLRateLimit`. +- The enum is `Extensible = true`. The dispatcher fires events like + `OnBeforeSetInterfaceCodeunit` so extensions can swap implementations. +- Many operations come in pairs for cursor-based pagination (e.g., + `GetCustomerIds` / `GetNextCustomerIds`). +- Naming convention: `ShpfyGQL[Operation].Codeunit.al`. +- Cost values are hand-tuned integers, not computed. The API version is + pinned in `ShpfyCommunicationMgt` (currently `2026-01`), not here. diff --git a/src/Apps/W1/Shopify/App/src/GraphQL/docs/patterns.md b/src/Apps/W1/Shopify/App/src/GraphQL/docs/patterns.md new file mode 100644 index 0000000000..e851e6132e --- /dev/null +++ b/src/Apps/W1/Shopify/App/src/GraphQL/docs/patterns.md @@ -0,0 +1,91 @@ +# GraphQL query builder pattern + +## The interface + +`Shpfy IGraphQL` defines the contract every query must fulfill: + +- `GetGraphQL(): Text` -- returns the complete JSON-encoded GraphQL + request body, including the `"query"` key and optionally `"variables"`. +- `GetExpectedCost(): Integer` -- returns the estimated cost points for + Shopify's rate limiter. + +That is the entire interface. No setup, no state, no dependencies. + +## Enum-to-codeunit mapping + +AL's interface-implementing enums do the dispatch. The `Shpfy GraphQL Type` +enum declares ~143 values, each with an `Implementation` clause pointing +to a specific codeunit. When code needs to execute a query, it passes the +enum value to `ShpfyGraphQLQueries.GetQuery()`, which resolves the +interface implementation via `IGraphQL := GraphQLType` (AL's implicit +interface cast from enum to interface). + +This means adding a new API operation requires exactly two things: a new +codeunit implementing `Shpfy IGraphQL`, and a new enum value pointing to +it. + +## Parameter substitution + +Parameters use named `{{placeholder}}` tokens inside the query string. +The dispatcher receives a `Dictionary of [Text, Text]` and does literal +string replacement: + +``` +GraphQL := GraphQL.Replace('{{' + Param + '}}', Parameters.Get(Param)); +``` + +This is deliberately simple. No escaping, no type coercion, no query +builder DSL. The caller is responsible for formatting values correctly +(e.g., wrapping strings in quotes within the GraphQL body itself, or +formatting GIDs as `gid://shopify/Customer/{{CustomerId}}`). + +## Concrete example: ShpfyGQLCustomer + +`Codeunits/ShpfyGQLCustomer.Codeunit.al` fetches a single customer by ID. + +`GetGraphQL()` returns a JSON string containing a query that takes a +`{{CustomerId}}` parameter embedded in a GID path: +`gid://shopify/Customer/{{CustomerId}}`. The query requests +`legacyResourceId`, name fields, addresses, tax info, tags, and up to 50 +metafields in a single call. + +`GetExpectedCost()` returns 12, reflecting the nested address and +metafield connections. + +The caller provides `Parameters.Add('CustomerId', Format(ShopifyCustomerId))` +and the dispatcher substitutes it into the GID. + +## Mutations follow the same pattern + +`Codeunits/ShpfyGQLModifyInventory.Codeunit.al` is a mutation, not a +query. It returns a JSON body with both `"query"` (containing the mutation +text) and `"variables"` (containing the input structure). The variables +include an empty `quantities` array that the `InventoryAPI` codeunit +populates after parsing the template. It also uses `{{IdempotencyKey}}` +for retry safety, substituted with a fresh GUID on each attempt. + +## Why not string concatenation or direct HTTP calls + +The alternative would be building GraphQL strings inline wherever they +are needed. This pattern avoids that because: + +- Each query is isolated in its own codeunit, making it easy to find, + read, and update when the Shopify API changes. +- The expected cost travels with the query, so rate limiting is automatic. +- The dispatcher fires events (`OnBeforeSetInterfaceCodeunit`, + `OnBeforeGetGrapQLInfo`, `OnAfterGetGrapQLInfo`, + `OnBeforeReplaceParameters`, `OnAfterReplaceParameters`) that let + extensions intercept or replace any query without modifying the + original codeunit. +- The enum is extensible, so extensions can add entirely new operations. + +## Cost tracking + +Each codeunit declares its cost as a hand-tuned integer. The rate limiter +(`ShpfyGraphQLRateLimit`) compares the expected cost against the available +budget from Shopify's throttle status and sleeps when needed. If the +available budget is unknown (first request), the restore rate defaults to +50 points/second. + +There is no automatic cost calculation. If Shopify changes query pricing, +the constants need manual adjustment. diff --git a/src/Apps/W1/Shopify/App/src/Inventory/docs/CLAUDE.md b/src/Apps/W1/Shopify/App/src/Inventory/docs/CLAUDE.md new file mode 100644 index 0000000000..44c9573347 --- /dev/null +++ b/src/Apps/W1/Shopify/App/src/Inventory/docs/CLAUDE.md @@ -0,0 +1,36 @@ +# Inventory + +Synchronizes BC item stock levels to Shopify inventory per location. +Push-only (BC to Shopify) -- reads current Shopify stock to detect drift, +then overwrites with calculated BC stock. + +## How it works + +`Shpfy Shop Location` (`Tables/ShpfyShopLocation.Table.al`) maps Shopify +locations to BC locations. The `Location Filter` field accepts filter +expressions (`EAST|WEST`, `A*`), not just single codes. `Default Location Code` +is separate, used on sales documents. + +Stock calculation is interface-driven. The `Shpfy Stock Calculation` enum +implements both `Shpfy Stock Calculation` (compute stock) and +`Shpfy IStock Available` (can this item type have stock). Three built-in +values: `Disabled`, `Projected Available Balance Today`, and +`Non-reserved Inventory`. The enum is `Extensible = true`. + +The `Shpfy Extended Stock Calculation` interface extends the base to also +receive the Shop Location record. `InventoryAPI` checks for this via `is` +type check and downcasts when available. + +Sync in `ShpfySyncInventory` imports Shopify stock per location, then +exports recalculated BC stock. `InventoryAPI` batches up to 250 quantities +per GraphQL mutation with idempotency keys and 3 retries on concurrency +errors. + +## Things to know + +- Each Shop Location has its own `Stock Calculation` setting -- different + locations can use different strategies. +- `OnAfterCalculationStock` event allows post-calculation stock adjustment. +- Fulfillment service locations cannot mix with standard locations for + `Default Product Location`. +- Negative stock is clamped to zero. Non-Inventory and Service items skip. diff --git a/src/Apps/W1/Shopify/App/src/Inventory/docs/extensibility.md b/src/Apps/W1/Shopify/App/src/Inventory/docs/extensibility.md new file mode 100644 index 0000000000..dbc2dc45d0 --- /dev/null +++ b/src/Apps/W1/Shopify/App/src/Inventory/docs/extensibility.md @@ -0,0 +1,82 @@ +# Inventory extensibility + +## Stock calculation interfaces + +There are three interfaces involved in stock calculation, and an event for +post-calculation adjustment. + +### Shpfy Stock Calculation + +The base interface (`Interface/ShpfyStockCalculation.Interface.al`): + +``` +procedure GetStock(var Item: Record Item): Decimal; +``` + +Receives an Item record with `Date Filter`, `Location Filter`, and +optionally `Variant Filter` already applied. Return the computed stock +quantity. Two built-in implementations exist: `Shpfy Balance Today` (uses +`ItemAvailabilityFormsMgt.CalcAvailQuantities` for projected available +balance) and `Shpfy Free Inventory` (returns `Inventory - Reserved Qty.`). + +### Shpfy Extended Stock Calculation + +Extends the base interface +(`Interface/ShpfyExtendedStockCalculation.Interface.al`): + +``` +procedure GetStock(var Item: Record Item; var ShopLocation: Record "Shpfy Shop Location"): Decimal; +``` + +Same as above but also receives the Shop Location record. This gives your +implementation access to the location's `Shop Code`, `Location Filter`, +and other configuration. The `InventoryAPI` checks at runtime whether your +implementation supports this extended interface via an `is` type check +and downcasts when it does. If your implementation only needs the Item +record, implement the base interface instead. + +### Shpfy IStock Available + +Controls whether an item type participates in stock sync at all +(`Interface/ShpfyIStockAvailable.Interface.al`): + +``` +procedure CanHaveStock(): Boolean; +``` + +The `Shpfy Stock Calculation` enum implements both `Shpfy Stock Calculation` +and `Shpfy IStock Available` simultaneously. The `Disabled` value maps to +`Shpfy Can Not Have Stock` (returns false); the active values map to +`Shpfy Can Have Stock` (returns true). When you add a custom enum value, +you must provide implementations for both interfaces. + +## Adding a custom stock calculation + +The `Shpfy Stock Calculation` enum is `Extensible = true`. To add a +custom strategy: + +- Create an enum extension adding your value to `Shpfy Stock Calculation`. +- Provide an `Implementation` clause mapping both + `Shpfy Stock Calculation` and `Shpfy IStock Available` to your + codeunits. +- Your `IStock Available` implementation should return true (otherwise + stock sync is skipped for that location). +- Your `Stock Calculation` implementation receives the Item with filters + already set. Optionally implement `Shpfy Extended Stock Calculation` if + you need the Shop Location record. + +## OnAfterCalculationStock event + +Fired by `Shpfy Inventory Events` +(`Codeunits/ShpfyInventoryEvents.Codeunit.al`) after the stock +calculation completes but before the value is exported. Parameters: + +- `Item: Record Item` -- the item record with filters applied +- `ShopifyShop: Record "Shpfy Shop"` -- the shop configuration +- `LocationFilter: Text` -- the location filter string from the Shop Location +- `var StockResult: Decimal` -- the calculated stock, modifiable + +Use this event when you need to adjust the final stock number regardless +of which calculation strategy is active. For example, reserving safety +stock or applying a multiplier. If you need to replace the calculation +entirely, implement a custom stock calculation enum value instead. diff --git a/src/Apps/W1/Shopify/App/src/Invoicing/docs/CLAUDE.md b/src/Apps/W1/Shopify/App/src/Invoicing/docs/CLAUDE.md new file mode 100644 index 0000000000..3a32efc2b2 --- /dev/null +++ b/src/Apps/W1/Shopify/App/src/Invoicing/docs/CLAUDE.md @@ -0,0 +1,20 @@ +# Invoicing + +Exports posted BC sales invoices to Shopify as completed orders. This is a one-directional push -- BC invoices become Shopify draft orders that are immediately completed and fulfilled. + +## How it works + +`ShpfyPostedInvoiceExport` drives the flow. It first validates the invoice is exportable: the bill-to customer must exist as a Shopify customer or company, payment terms must be mapped, the customer cannot be the default template customer, and lines must have valid quantities (positive integers) and non-empty item numbers. The invoice data is mapped to temporary `Shpfy Order Header` and `Shpfy Order Line` records, then passed to `ShpfyDraftOrdersAPI.CreateDraftOrder`, which builds and sends a `draftOrderCreate` GraphQL mutation. + +The draft order includes line items (with prices, weights, and descriptions), shipping and billing addresses, invoice discount as an applied discount, tax lines aggregated by VAT calculation type, and optional payment terms (NET or FIXED). Once created, the draft is immediately completed via `draftOrderComplete`, which converts it to a real Shopify order. Fulfillment orders for the new order are then auto-fulfilled via `ShpfyFulfillmentAPI`, marking everything as shipped. + +The `Shpfy Order Id` on the posted Sales Invoice Header tracks the result: a positive value means success, -1 means the Shopify call failed, and -2 means the invoice was not exportable. A `Shpfy Invoice Header` record and a document link are created on success. + +## Things to know + +- Draft orders are created with `taxExempt: true` and tax amounts are added as separate line items, because Shopify cannot replicate BC's exact tax calculations. +- Payment terms on the draft order use Shopify's `PaymentTermsTemplate` IDs from the Payment Terms mapping. If the invoice's payment terms code is not mapped, the primary (fallback) term is used. +- Invoices with non-integer or negative quantities are rejected during validation. +- The invoice's remaining amount determines whether the Shopify order is marked as unpaid (which triggers payment terms) or paid. +- Sales comment lines from the posted invoice are concatenated and sent as the draft order's note. +- Currency codes are resolved to ISO codes via the BC Currency table. diff --git a/src/Apps/W1/Shopify/App/src/Logs/docs/CLAUDE.md b/src/Apps/W1/Shopify/App/src/Logs/docs/CLAUDE.md new file mode 100644 index 0000000000..6e6d0abef0 --- /dev/null +++ b/src/Apps/W1/Shopify/App/src/Logs/docs/CLAUDE.md @@ -0,0 +1,19 @@ +# Logs + +Captures API call logs, raw data snapshots, and skipped record notifications across the Shopify Connector. This module provides the diagnostic infrastructure that all other modules rely on for troubleshooting. + +## How it works + +The `Shpfy Log Entry` table records each API call with URL, HTTP method, status code, retry count, and GraphQL query cost. Request and response payloads are stored as BLOBs, with the first 50 characters duplicated into `Request Preview` and `Response Preview` fields for grid display without loading the full blob. Logging is controlled per shop via the `Logging Mode` setting (Disabled, All, or Errors only). + +The `Shpfy Data Capture` table provides a separate mechanism for storing raw JSON snapshots of imported Shopify entities. It is keyed by source table and record SystemId, uses content hashing to avoid storing duplicates, and is used throughout the connector (orders, fulfillments, transactions, refunds, etc.) to preserve the original Shopify payload for debugging. + +The `Shpfy Skipped Record` table and its codeunit handle records that could not be processed during sync. When a record is skipped, the reason is logged and a notification is sent to the user. The `ShpfyLogEntries` codeunit adds escalation support -- for HTTP 500 errors within 14 days, it can generate a downloadable escalation report containing the request ID, timestamp, store URL, API version, and full request/response data. + +## Things to know + +- Log entries can be bulk-deleted by age using `DeleteEntries`. Data captures are cleaned up automatically when their parent records are deleted. +- The `Shpfy Request Id` field stores Shopify's request correlation ID, which is needed when escalating server-side errors to Shopify support. +- Skipped records are only logged when the shop's logging mode is not Disabled. +- The `Shpfy Data Capture` table uses a hash-based dedup -- if the JSON payload hash matches the last capture for that record, no new entry is created. +- Escalation reports are only available for 500-status entries less than 14 days old, and require a successful connection test before download. diff --git a/src/Apps/W1/Shopify/App/src/Metafields/docs/CLAUDE.md b/src/Apps/W1/Shopify/App/src/Metafields/docs/CLAUDE.md new file mode 100644 index 0000000000..8548ac8d40 --- /dev/null +++ b/src/Apps/W1/Shopify/App/src/Metafields/docs/CLAUDE.md @@ -0,0 +1,36 @@ +# Metafields + +Extensible custom field system that attaches key-value metadata to Shopify +entities: products, variants, customers, and companies. + +## How it works + +The `Shpfy Metafield` table (`Tables/ShpfyMetafield.Table.al`) stores all +metafields. Each record has an `Owner Type` enum and `Owner Id` +identifying the parent entity, plus a `Parent Table No.` mapping to the +BC table. Two interface systems handle polymorphism: + +- `Shpfy IMetafield Type` -- per-value-type behavior (validation, assist + edit, example values). The `Shpfy Metafield Type` enum maps 26 types + (boolean, money, url, references, etc.) to codeunits in + `Codeunits/IMetafieldType/`. +- `Shpfy IMetafield Owner Type` -- per-owner behavior (table ID lookup, + Shopify metafield retrieval, shop code resolution, edit permissions). + Four implementations in `Codeunits/IOwnerType/` cover Customer, Product, + Variant, and Company. + +The public API is `Shpfy Metafields` codeunit +(`Codeunits/ShpfyMetafields.Codeunit.al`, Access = Public) exposing sync +and definition retrieval methods. + +## Things to know + +- Default namespace: `'Microsoft.Dynamics365.BusinessCentral'` (set in + OnInsert trigger). +- Negative IDs = BC-created metafields not yet synced to Shopify. +- Money type requires currency match with the shop -- enforced in the + Value OnValidate trigger via `CheckShopCurrency`. +- Legacy types `string` and `integer` are blocked at validation time, + directing users to `single_line_text_field` and `number_integer`. +- Both the type enum (`Extensible = false`) and owner type enum are not + extensible by third parties. diff --git a/src/Apps/W1/Shopify/App/src/Metafields/docs/extensibility.md b/src/Apps/W1/Shopify/App/src/Metafields/docs/extensibility.md new file mode 100644 index 0000000000..ac0209c9c8 --- /dev/null +++ b/src/Apps/W1/Shopify/App/src/Metafields/docs/extensibility.md @@ -0,0 +1,73 @@ +# Metafields extensibility + +## IMetafieldType interface + +The `Shpfy IMetafield Type` interface defines four methods: + +- `HasAssistEdit(): Boolean` -- whether a dialog-based editor exists for + this type. +- `IsValidValue(Value: Text): Boolean` -- validates a raw text value + against the type's rules. +- `AssistEdit(var Value: Text[2048]): Boolean` -- opens an assist-edit + dialog and returns the modified value. Only called when `HasAssistEdit` + returns true. +- `GetExampleValue(): Text` -- returns a sample value shown in error + messages when validation fails. + +Each enum value in `Shpfy Metafield Type` maps to a codeunit in +`Codeunits/IMetafieldType/`. For example, `ShpfyMtfldTypeMoney` parses +JSON with `amount` and `currency_code`, validates that the currency exists +in BC's Currency table, and provides an assist-edit page. Simpler types +like `ShpfyMtfldTypeBoolean` just check for `"true"` or `"false"`. + +The enum is `Extensible = false`, so third parties cannot currently add +new metafield value types through enum extension. To support a new +Shopify metafield type, the enum and a new implementing codeunit must be +added to the base connector code. + +## IMetafieldOwnerType interface + +The `Shpfy IMetafield Owner Type` interface defines four methods: + +- `GetTableId(): Integer` -- returns the BC table ID where the owner + entity lives (e.g., `Database::"Shpfy Product"`). +- `RetrieveMetafieldIdsFromShopify(OwnerId: BigInteger): Dictionary of [BigInteger, DateTime]` + -- calls the Shopify API to get current metafield IDs and their last + update timestamps for a specific owner. +- `GetShopCode(OwnerId: BigInteger): Code[20]` -- resolves which shop + record the owner belongs to. +- `CanEditMetafields(Shop: Record "Shpfy Shop"): Boolean` -- checks + whether metafield editing is allowed for this owner type in the given + shop configuration. + +Four implementations exist in `Codeunits/IOwnerType/`: Customer, Product, +Variant, and Company. Each knows how to look up its entity table, call the +right GraphQL query for metafield IDs (e.g., `ProductMetafieldIds`, +`CustomerMetafieldIds`), and extract the shop code from the owner record. + +## How the type system works at runtime + +When a user edits the Value field on a metafield record, the table's +OnValidate trigger does: + +``` +IMetafieldType := Rec.Type; +if not IMetafieldType.IsValidValue(Value) then + Error(ValueNotValidErr + IMetafieldType.GetExampleValue()); +``` + +AL resolves `Rec.Type` (the enum value) to the matching interface +implementation, so each type's validation runs automatically. The same +dispatch pattern applies when the UI calls `AssistEdit`. + +## Registering new owner types + +Adding a new owner type (if the enum were extensible) would require: + +- A new enum value in `Shpfy Metafield Owner Type` pointing to a new + codeunit. +- The codeunit implements all four `IMetafieldOwnerType` methods. +- A corresponding GraphQL query codeunit to fetch metafield IDs from + Shopify for that entity type. +- An entry in the `GetOwnerType` case statement in the Metafield table + to map the BC table number back to the enum value. diff --git a/src/Apps/W1/Shopify/App/src/Order Fulfillments/docs/CLAUDE.md b/src/Apps/W1/Shopify/App/src/Order Fulfillments/docs/CLAUDE.md new file mode 100644 index 0000000000..c7d77e78fb --- /dev/null +++ b/src/Apps/W1/Shopify/App/src/Order Fulfillments/docs/CLAUDE.md @@ -0,0 +1,20 @@ +# Order fulfillments + +Manages two distinct Shopify concepts: Fulfillment Orders (Shopify's plan for how an order should be shipped) and Fulfillments (the actual shipment records). Fulfillment Orders are created by Shopify per location; Fulfillments are created by BC when posting sales shipments. + +## How it works + +When an order is imported, `ShpfyFulfillmentOrdersAPI.GetShopifyFulfillmentOrdersFromShopifyOrder` fetches the fulfillment orders for that order. Each fulfillment order has a header (`Shpfy FulFillment Order Header`) with status, location, delivery method type, and request status, plus line items (`Shpfy FulFillment Order Line`) with remaining quantities. These are the "work items" that tell the Shipping module which items at which location still need to be shipped. + +Fulfillments (actual shipments) are tracked in `Shpfy Order Fulfillment` and `Shpfy Fulfillment Line`. The `ShpfyOrderFulfillments` codeunit imports fulfillment data from Shopify, including tracking info (numbers, URLs, companies) and line-level quantities. It handles pagination for fulfillments with many line items and detects gift card fulfillments to trigger gift card retrieval. + +The module also manages BC's fulfillment service registration. On first outgoing request, it registers a fulfillment service with Shopify (`CreateFulfillmentService`), which allows BC to appear as a fulfillment location. Assigned fulfillment orders (where Shopify has routed work to BC's service) are tracked via `GetAssignedFulfillmentOrders` and must be accepted before they can be fulfilled. + +## Things to know + +- Fulfillment Orders are Shopify's concept -- they represent "what needs to ship from where." Fulfillments are the actual shipment records. +- The `Request Status` enum (SUBMITTED, ACCEPTED, etc.) tracks the fulfillment service handshake -- BC must accept assigned requests before creating fulfillments. +- Fulfillment order lines carry `Remaining Quantity` and `Quantity to Fulfill` -- the Shipping module uses these to match BC shipment lines to Shopify fulfillment order lines. +- `Delivery Method Type` distinguishes shipping, pickup, local delivery, and other methods. +- The fulfillment service is auto-registered on first outgoing sync if `Allow Outgoing Requests` is enabled. +- Multiple tracking numbers per fulfillment are supported -- they are stored as comma-separated values. diff --git a/src/Apps/W1/Shopify/App/src/Order Refunds/docs/CLAUDE.md b/src/Apps/W1/Shopify/App/src/Order Refunds/docs/CLAUDE.md new file mode 100644 index 0000000000..f0db2a97fa --- /dev/null +++ b/src/Apps/W1/Shopify/App/src/Order Refunds/docs/CLAUDE.md @@ -0,0 +1,20 @@ +# Order refunds + +Imports refund data from Shopify -- headers, line items, and shipping line refunds. This module is purely a data model and import layer; the actual processing of refunds into BC credit memos lives in the Order Return Refund Processing module. + +## How it works + +`ShpfyRefundsAPI` imports refunds by ID. For each refund, it fetches the header (total refunded amount, note, associated return ID, dual-currency amounts), the refund lines (with quantities, restock types, and per-line amounts in both shop and presentment currencies), and refund shipping lines (shipping refund amounts). All amounts are stored in both the shop's base currency and the presentment currency. + +Refund lines are linked back to order lines via `Order Line Id` and carry a `Restock Type` (NoRestock, Cancel, Return, LegacyRestock) that indicates what happened to the inventory. The `Can Create Credit Memo` flag is calculated based on whether the refund has a non-zero total or is linked to a return -- refunds that were already factored into order import (quantity reductions) cannot produce credit memos. + +If the refund is associated with a return, the module collects return locations from the Returns API to populate the `Location Id` on refund lines, since Shopify does not always include location data directly on the refund. + +## Things to know + +- A refund can exist without a return -- for example, an appeasement refund or a partial amount adjustment. +- The `Can Create Credit Memo` flag prevents double-processing: refunds that reduced order quantities during import are flagged as non-processable. +- The `Return Id` on the refund header links to the `Shpfy Return Header` if the refund was created from a return flow. +- Refund transactions are cross-updated -- `UpdateTransactions` stamps the `Refund Id` onto the corresponding `Shpfy Order Transaction` records. +- Refund lines reference order lines, not products directly -- item details come from FlowFields that look up the original order line. +- The `Note` field is stored as a BLOB to accommodate long refund notes. diff --git a/src/Apps/W1/Shopify/App/src/Order Return Refund Processing/docs/CLAUDE.md b/src/Apps/W1/Shopify/App/src/Order Return Refund Processing/docs/CLAUDE.md new file mode 100644 index 0000000000..1f61cf1088 --- /dev/null +++ b/src/Apps/W1/Shopify/App/src/Order Return Refund Processing/docs/CLAUDE.md @@ -0,0 +1,21 @@ +# Order Return Refund Processing + +The strategy layer that controls how Shopify returns and refunds are processed in Business Central. This module does not hold the return/refund data model (that lives in `Order Refunds` and `Order Returns`). It holds the processing strategies and the interfaces for document creation. + +## How it works + +The `IReturnRefund Process` interface (implemented via the extensible enum `Shpfy ReturnRefund ProcessType`) defines three operations: whether import is needed for a given source document type, whether a sales document can be created, and the actual document creation. The enum has three implementations. + +- **Default** (`ShpfyRetRefProcDefault`) -- does nothing. Import is not needed, documents cannot be created. This is the "disabled" state. +- **Import Only** (`ShpfyRetRefProcImportOnly`) -- returns and refunds are imported from Shopify (so they appear in staging tables and reduce order line quantities), but no credit memos are auto-created. +- **Auto Create Credit Memo** (`ShpfyRetRefProcCrMemo`) -- imports data and auto-creates sales credit memos (or return orders, depending on `Shop."Process Returns As"`). It validates preconditions: the refund must not already be processed, the parent order must exist and must itself be processed. On success, `ShpfyCreateSalesDocRefund` builds the credit memo with lines from refund lines, return lines, refund shipping lines, and a balance line for any remaining amount. + +The `IDocument Source` interface (implemented via `Shpfy Source Document Type` enum) provides error reporting back to the source record. The Refund implementation (`ShpfyIDocSourceRefund`) writes errors to the refund header. The extended interface `Shpfy Extended IDocument Source` adds call stack capture for deeper diagnostics. + +## Things to know + +- This module is separate from `Order Refunds` and `Order Returns` which hold the data tables. This module holds only the processing strategies and the credit memo creation logic. +- Non-restocked items get posted to a different G/L account (`Refund Acc. non-restock Items`) than restocked items (which are returned as inventory items). Cancelled restock types go to the general `Refund Account`. +- The balance line (`CreateSalesLinesFromRemainingAmount`) catches any difference between the sum of the created sales lines and the total refund amount, posting it to `Refund Account`. This handles adjustments, rounding, and partial refunds that do not fully decompose into line items. You can skip this auto-balancing via the `OnBeforeCreateSalesLinesFromRemainingAmount` event. +- `ShpfyProcessOrders` triggers refund processing after order processing, but only when the shop's strategy is "Auto Create Credit Memo". +- Currency handling for refund documents respects `Processed Currency Handling` from the original order, not the current shop setting. This ensures consistency if the shop's currency handling changed between order creation and refund. diff --git a/src/Apps/W1/Shopify/App/src/Order Return Refund Processing/docs/extensibility.md b/src/Apps/W1/Shopify/App/src/Order Return Refund Processing/docs/extensibility.md new file mode 100644 index 0000000000..040ef6bc33 --- /dev/null +++ b/src/Apps/W1/Shopify/App/src/Order Return Refund Processing/docs/extensibility.md @@ -0,0 +1,23 @@ +# Extensibility + +## IReturnRefund Process interface + +The `Shpfy ReturnRefund ProcessType` enum is `Extensible = true`. To add a custom refund processing strategy, add a new enum value with an implementation of `Shpfy IReturnRefund Process`. The three procedures you must implement are: + +- `IsImportNeededFor(SourceDocumentType)` -- return true if your strategy requires returns/refunds to be imported from Shopify during order sync. If false, the refund and return API calls are skipped entirely during import. +- `CanCreateSalesDocumentFor(SourceDocumentType, SourceDocumentId, ErrorInfo)` -- validate preconditions. Return true to allow creation. On failure, populate the `ErrorInfo` record with an error message; set `Verbosity` to `Error` for hard failures or `Warning` for soft skips. +- `CreateSalesDocument(SourceDocumentType, SourceDocumentId)` -- build and return the Sales Header for the credit memo / return order. + +## IDocument Source interface + +The `Shpfy Source Document Type` enum is also `Extensible = true` and implements `Shpfy IDocument Source`. The interface has a single method, `SetErrorInfo`, which writes error information back to the source document record. The extended interface `Shpfy Extended IDocument Source` adds `SetErrorCallStack` for detailed diagnostics. When your `IReturnRefund Process` implementation calls `CreateSalesDocument`, errors are routed to the appropriate source document through this interface. + +## Events in ShpfyRefundProcessEvents + +Events are published by `ShpfyRefundProcessEvents` (codeunit 30247). + +- `OnBeforeCreateSalesHeader` / `OnAfterCreateSalesHeader` -- wrap credit memo header creation from a refund. The Before event supports the Handled pattern to replace the built-in header creation entirely. +- `OnBeforeCreateItemSalesLine` / `OnAfterCreateItemSalesLine` -- wrap each refund line's conversion to a sales line. Handled pattern supported on Before. +- `OnBeforeCreateItemSalesLineFromReturnLine` / `OnAfterCreateItemSalesLineFromReturnLine` -- same pattern but for return lines (used when the refund has a linked return and no refund lines). +- `OnAfterProcessSalesDocument` -- fires after the credit memo is created and released. +- `OnBeforeCreateSalesLinesFromRemainingAmount` -- fires before the auto-balance line is created. Set `SkipBalancing = true` to suppress the balance line entirely, useful if your custom logic already ensures the document total matches the refund amount. diff --git a/src/Apps/W1/Shopify/App/src/Order handling/docs/CLAUDE.md b/src/Apps/W1/Shopify/App/src/Order handling/docs/CLAUDE.md new file mode 100644 index 0000000000..f55bac7d95 --- /dev/null +++ b/src/Apps/W1/Shopify/App/src/Order handling/docs/CLAUDE.md @@ -0,0 +1,23 @@ +# Order handling + +Imports Shopify orders into Business Central, maps them to customers and items, and creates sales orders or invoices. + +## How it works + +The pipeline has three stages. + +- **Discovery**: `ShpfyOrdersAPI` queries the Shopify GraphQL API for orders updated since the last sync. It writes lightweight rows into the `Orders to Import` queue table, not full order data. The query distinguishes first-time sync (open orders only) from incremental sync (all orders by updated-at). + +- **Import**: `ShpfyImportOrder` fetches the full order JSON for each queued entry, populates `Order Header` and `Order Line` records, and pulls in related data (tax lines, attributes, risks, shipping charges, transactions, fulfillment orders, returns, refunds). It detects conflicts when a previously processed order is edited in Shopify and flags them with `Has Order State Error`. + +- **Processing**: `ShpfyProcessOrder` runs mapping then document creation. `ShpfyOrderMapping.DoMapping` resolves Shopify customers to BC customer numbers (with B2B company mapping as a separate path) and maps each order line's variant to an item/variant/UoM. `ShpfyProcessOrder.CreateHeaderFromShopifyOrder` builds a Sales Header with all three address contexts (sell-to, ship-to, bill-to), then `CreateLinesFromShopifyOrder` adds item lines, tip lines (G/L), gift card lines (G/L), shipping charge lines, and a cash rounding line. Global discounts that were not allocated to individual lines are applied as invoice discount. If the shop has "Auto Release Sales Orders" enabled, the document is released immediately. + +## Things to know + +- Every monetary field exists in two flavours: shop currency (`Currency Code`) and presentment currency (`Presentment Currency Code`). Which one flows to the sales document depends on the shop's `Currency Handling` setting. +- `Orders to Import` is a queue, not a mirror. It is populated during discovery and consumed during import. It carries summary data and error tracking (`Has Error`, blob `Error Message` and `Error Call Stack`). +- The `Processed` flag on `Order Header` prevents re-processing. `IsProcessed()` also checks `Doc. Link To Doc.` so that orders linked to BC documents through any path are considered processed. +- `Document Date` has a validation trigger that calls `TestField("Sales Order No.", '')`, which prevents changes after a sales order has been created. +- Fulfilled orders become invoices instead of sales orders when `Create Invoices From Orders` is enabled on the shop. +- `ShpfyProcessOrders` (plural) is the batch entry point. After processing orders it also processes refunds if the shop uses the "Auto Create Credit Memo" strategy. +- `ShpfyOrders` (public API codeunit) exposes `MarkAsPaid` and `CancelOrder` for external callers. diff --git a/src/Apps/W1/Shopify/App/src/Order handling/docs/business-logic.md b/src/Apps/W1/Shopify/App/src/Order handling/docs/business-logic.md new file mode 100644 index 0000000000..010426cc84 --- /dev/null +++ b/src/Apps/W1/Shopify/App/src/Order handling/docs/business-logic.md @@ -0,0 +1,69 @@ +# Business logic + +## Pipeline overview + +```mermaid +flowchart TD + A[ShpfyOrdersAPI.GetOrdersToImport] -->|writes| B[Orders to Import queue] + B -->|each row| C[ShpfyImportOrder] + C -->|creates/updates| D[Order Header + Lines] + D --> E{User or auto-process} + E -->|ShpfyProcessOrders| F[ShpfyProcessOrder.OnRun] + F --> G[ShpfyOrderMapping.DoMapping] + G -->|success| H[CreateHeaderFromShopifyOrder] + H --> I[CreateLinesFromShopifyOrder] + I --> J[ApplyGlobalDiscounts] + J --> K{Auto Release?} + K -->|yes| L[ReleaseSalesDocument] + K -->|no| M[Done] + G -->|failure| N[Error: not everything mapped] +``` + +## Phase 1: order discovery + +`ShpfyOrdersAPI.GetOrdersToImport` paginates through Shopify's GraphQL `orders` connection. On first sync (no prior sync time), it uses `GetOpenOrdersToImport` which returns only open orders. On subsequent syncs it uses `GetOrdersToImport` filtered by `updatedAt` since the last sync time. For each order node it populates or updates a row in `Orders to Import`, computing the `Import Action` (New or Update) by checking whether an `Order Header` already exists. Closed orders are only imported as updates, never as new. + +## Phase 2: order import + +`ShpfyImportOrder.ImportOrderAndCreateOrUpdate` does the heavy lifting. It fetches the full order JSON via a second GraphQL call (`GetOrderHeader`), retrieves order lines in a paginated loop (`GetOrderLines` / `GetNextOrderLines`), and populates the staging tables. + +For already-processed orders, it runs conflict detection. A conflict is flagged if the current total items quantity increased, the line item composition changed (checked via a hash of line IDs), or the shipping charges amount changed. Conflicting orders get `Has Order State Error = true`. + +After populating the header and lines, the import calls into related modules: fulfillment orders, shipping charges, transactions, returns (if the return/refund process requires import), and refunds. It then adjusts order line quantities and header amounts by subtracting refund line quantities and amounts. Zero-quantity lines are deleted. If the order is fully fulfilled and paid, and `Archive Processed Orders` is enabled, it closes the order in Shopify via the `CloseOrder` GraphQL mutation. + +## Phase 3: mapping and document creation + +### Customer mapping + +```mermaid +flowchart TD + A{B2B order?} -->|no| B[MapHeaderFields] + A -->|yes| C[MapB2BHeaderFields] + B --> D{Customer template exists for country?} + D -->|yes, with default customer| E[Use default customer from template] + D -->|no| F{Customer import = None?} + F -->|yes| G[Use shop default customer] + F -->|no| H[CustomerMapping.DoMapping] + H --> I[Resolve sell-to and bill-to separately] + C --> J{Company Location Id set?} + J -->|yes| K[MapSellToBillToCustomersFromCompanyLocation] + J -->|no| L[CompanyMapping.DoMapping] +``` + +`ShpfyOrderMapping.DoMapping` first resolves the customer (or company for B2B), then iterates order lines. Tip lines require a configured `Tip Account`, gift card lines require a `Sold Gift Card Account`, and regular lines go through `MapVariant` which looks up the Shopify variant, auto-imports the product if needed, and resolves the BC item, variant code, and unit of measure. + +The mapping also resolves shipping method (from `Shpfy Shipment Method Mapping` by shipping charge title), shipping agent, and payment method (from the order's successful transactions, only if a single payment method is found). + +### Sales document creation + +`ShpfyProcessOrder.CreateHeaderFromShopifyOrder` inserts a Sales Header. The document type is Order by default, or Invoice if the order is already fulfilled and `Create Invoices From Orders` is enabled. It copies all three address blocks from the Shopify order, sets currency based on the shop's `Currency Handling`, applies the document date, shipping method, payment method, tax area, and payment terms. It writes a `Doc. Link To Doc.` record to tie the Shopify order to the BC document. If `Order Attributes To Shopify` is enabled, it pushes the BC document number back to Shopify as an order attribute. + +`CreateLinesFromShopifyOrder` iterates order lines and creates corresponding sales lines. Tips go to the tip G/L account, gift cards to the sold gift card G/L account, and regular items are validated with location code resolution from `Shpfy Shop Location`. Shipping charges become separate sales lines, either as G/L account lines using the shop's `Shipping Charges Account` or as item charge lines when configured through `Shpfy Shipment Method Mapping`. Item charges are automatically assigned to the item lines. + +`ApplyGlobalDiscounts` calculates the portion of the order's total discount that was not allocated to individual lines or shipping charges, and applies it as an invoice discount on the sales header. + +A final cash rounding line is created if the order has a non-zero `Payment Rounding Amount`, posted to the shop's `Cash Roundings Account`. + +## Error handling + +`ShpfyProcessOrders.ProcessShopifyOrder` wraps `ShpfyProcessOrder.Run` in a `if not ... Run` pattern. On failure it captures the error text into the order header's `Error Message`, clears the sales document number, and calls `CleanUpLastCreatedDocument` to delete the partially created sales document. On success it sets `Processed = true` and records the `Processed Currency Handling` so refund processing later knows which currency was used. diff --git a/src/Apps/W1/Shopify/App/src/Order handling/docs/data-model.md b/src/Apps/W1/Shopify/App/src/Order handling/docs/data-model.md new file mode 100644 index 0000000000..6b79b2a396 --- /dev/null +++ b/src/Apps/W1/Shopify/App/src/Order handling/docs/data-model.md @@ -0,0 +1,51 @@ +# Data model + +## Core entity relationships + +```mermaid +erDiagram + OrderHeader ||--o{ OrderLine : "Shopify Order Id" + OrderHeader ||--o{ OrderTaxLine : "Shopify Order Id = Parent Id (header-level)" + OrderLine ||--o{ OrderTaxLine : "Line Id = Parent Id (line-level)" + OrdersToImport }o--|| OrderHeader : "Id = Shopify Order Id" + OrderHeader ||--o{ OrderAttribute : "Shopify Order Id = Order Id" +``` + +## Order Header and Order Line + +`Shpfy Order Header` (table 30118) is keyed on `Shopify Order Id` (BigInteger). It carries the full snapshot of a Shopify order: three complete address blocks (sell-to, ship-to, bill-to), financial totals, status enums, and the BC-side output fields (`Sales Order No.`, `Sales Invoice No.`, `Sell-to Customer No.`, `Bill-to Customer No.`). + +`Shpfy Order Line` (table 30119) is keyed on `(Shopify Order Id, Line Id)`. Each line holds the Shopify product/variant reference plus the mapped BC item (`Item No.`, `Variant Code`, `Unit of Measure Code`). Boolean flags `Gift Card` and `Tip` classify non-inventory lines. The secondary key on `(Shopify Order Id, Gift Card, Tip)` maintains a SIFT index on `Quantity`, which drives the header's `Total Quantity of Items` FlowField (excluding tips and gift cards). + +Deleting a header cascades. The `OnDelete` trigger on Order Header explicitly deletes order lines, return headers, refund headers, data capture records, fulfillment order headers, and order fulfillments. + +## Dual-currency design + +Every amount field on both header and line exists in two versions: shop currency and presentment currency. The header stores `Currency Code` (the shop's settlement currency) and `Presentment Currency Code` (the currency the buyer saw). Lines reference the header for formatting via local helper procedures `OrderCurrencyCode()` and `OrderPresentmentCurrencyCode()`. During processing, the shop's `Currency Handling` setting determines which currency column is used to populate the BC sales document. + +## Order Tax Line (polymorphic parent) + +`Shpfy Order Tax Line` (table 30122) uses a `Parent Id` field that can point to either an Order Header (`Shopify Order Id`) or an Order Line (`Line Id`). The `OrderCurrencyCode()` helper attempts to resolve the parent as an Order Line first, then walks up to the header. This polymorphic key is not enforced by a table relation; the code simply tries both lookups. The `Channel Liable` flag indicates marketplace-collected taxes, and the header has a FlowField `Channel Liable Taxes` that checks for their existence. + +## Orders to Import (queue table) + +`Shpfy Orders to Import` (table 30121) is a transient queue populated by `ShpfyOrdersAPI.GetOrdersToImport` and consumed by `ShpfyImportOrder`. It carries summary fields (amount, quantity, financial status, fulfillment status, tags) so users can review and filter before importing. The `Import Action` enum distinguishes `New` from `Update`. Error tracking uses blob fields for the message and call stack since error text can be long. + +## Supporting tables + +- `Shpfy Order Attribute` (table 30116) stores key-value pairs per order, keyed on `(Order Id, Key)`. The value field was widened from 250 to 2048 characters. +- `Shpfy Order Line Attribute` (table 30149) stores key-value pairs per order line, keyed on `(Order Id, Order Line Id, Key)` where `Order Line Id` is a Guid (the line's SystemId). +- `Shpfy Order Disc.Appl.` (table 30117) captures Shopify discount applications with allocation method, target selection, target type, and value type. +- `Shpfy Order Payment Gateway` (table 30120) records which payment gateways were used, keyed on `(Order Id, Name)`. + +## Table extensions on BC tables + +`Shpfy Sales Header` (tableextension 30101) adds `Shpfy Order Id`, `Shpfy Order No.`, and `Shpfy Refund Id` to the Sales Header. `Shpfy Sales Line` (tableextension 30104) adds `Shpfy Order Line Id`, `Shpfy Order No.`, `Shpfy Refund Id`, `Shpfy Refund Line Id`, and `Shpfy Refund Shipping Line Id` to the Sales Line. These fields link the BC sales documents back to their Shopify source records. + +## B2B fields + +The Order Header has a cluster of B2B fields: `Company Id`, `Company Main Contact Id`, `Company Main Contact Email`, `Company Main Contact Phone No.`, `Company Main Contact Cust. Id`, `Company Location Id`, `B2B` (boolean), and `PO Number`. When `B2B` is true, mapping takes a different path through `MapB2BHeaderFields` in `ShpfyOrderMapping`. + +## High Risk + +`High Risk` is a FlowField (`CalcFormula = exist`) that checks for any `Shpfy Order Risk` record with `Level = High` for the order. It is not stored; it is calculated on demand. diff --git a/src/Apps/W1/Shopify/App/src/Order handling/docs/extensibility.md b/src/Apps/W1/Shopify/App/src/Order handling/docs/extensibility.md new file mode 100644 index 0000000000..85f5341e6c --- /dev/null +++ b/src/Apps/W1/Shopify/App/src/Order handling/docs/extensibility.md @@ -0,0 +1,31 @@ +# Extensibility + +All events are published by `ShpfyOrderEvents` (codeunit 30162). Events marked `[InternalEvent]` use the Handled pattern for complete override; events marked `[IntegrationEvent]` are observe-only (before/after pairs). + +## Customer and company mapping + +- `OnBeforeMapCustomer` / `OnAfterMapCustomer` -- wrap the customer resolution for B2C orders. Setting `Handled = true` on the Before event skips the built-in mapping entirely, letting you assign `Sell-to Customer No.` and `Bill-to Customer No.` yourself. +- `OnBeforeMapCompany` / `OnAfterMapCompany` -- same pattern for B2B orders. + +## Shipping and payment mapping + +- `OnBeforeMapShipmentMethod` / `OnAfterMapShipmentMethod` -- override how the shipping charge title maps to a BC shipment method code. +- `OnBeforeMapShipmentAgent` / `OnAfterMapShipmentAgent` -- override shipping agent resolution. +- `OnBeforeMapPaymentMethod` / `OnAfterMapPaymentMethod` -- override how order transactions map to a BC payment method code. + +## Sales document creation + +- `OnBeforeCreateSalesHeader` / `OnAfterCreateSalesHeader` -- the Before event exposes the Handled pattern via the `IsHandled` parameter. Set it to true to create the Sales Header yourself (you must also set `LastCreatedDocumentId` so cleanup works on failure). The After event lets you modify the header after the built-in logic runs. +- `OnBeforeProcessSalesDocument` / `OnAfterProcessSalesDocument` -- wrap the entire processing pipeline (mapping + document creation + release). The After event receives both the Sales Header and the Order Header. + +## Sales line creation + +- `OnBeforeCreateItemSalesLine` / `OnAfterCreateItemSalesLine` -- wrap the creation of each item/tip/gift card sales line. The Before event supports the Handled pattern. When handled, the built-in line creation is skipped but the After event still fires. Use this to customize pricing, account assignment, or to skip specific lines entirely. +- `OnBeforeCreateShippingCostSalesLine` / `OnAfterCreateShippingCostSalesLine` -- wrap the creation of each shipping charge line. Also supports the Handled pattern. + +## Import and status conversion + +- `OnAfterImportShopifyOrderHeader` -- fires after the order header JSON is parsed and the header record is updated. The `IsNew` parameter indicates first import vs. update. +- `OnAfterCreateShopifyOrderAndLines` -- fires after both header and lines are fully imported and related records (tax lines, attributes, fulfillments) are created. +- `OnAfterConsiderRefundsInQuantityAndAmounts` -- fires after each order line's quantity and the header's amounts are adjusted for refunds. +- `OnBeforeConvertToFinancialStatus`, `OnBeforeConvertToFulfillmentStatus`, `OnBeforeConvertToOrderReturnStatus` -- internal events that let you override enum conversion from Shopify's status strings, useful when Shopify adds new status values before the enum is updated. diff --git a/src/Apps/W1/Shopify/App/src/Payments/docs/CLAUDE.md b/src/Apps/W1/Shopify/App/src/Payments/docs/CLAUDE.md new file mode 100644 index 0000000000..2ef69c1090 --- /dev/null +++ b/src/Apps/W1/Shopify/App/src/Payments/docs/CLAUDE.md @@ -0,0 +1,19 @@ +# Payments + +Handles Shopify Payments data -- payouts (bulk settlement transfers to the merchant's bank), payment transactions (individual movements within those payouts), B2B payment terms mapping, and chargeback disputes. + +## How it works + +`ShpfyPayments` coordinates two sync flows: payouts and disputes. Payout sync runs in four steps: update payout IDs on orphaned payment transactions, refresh pending payout statuses, import new payment transactions (from `shopifyPaymentsAccount.balanceTransactions`), and import new payouts. This incremental approach uses `SinceId` to avoid re-fetching old records, and processes transactions in batches of 200 when updating payout associations. + +Disputes are synced similarly -- unfinished disputes (not Won/Lost) are polled for status updates, then new disputes are imported. Each dispute links to its source order and carries reason codes, evidence deadlines, and finalization dates. + +The `Shpfy Payment Terms` table maps Shopify payment terms templates (NET, FIXED, etc.) to BC Payment Terms Codes. These are used by the Invoicing module when creating draft orders with B2B payment schedules. One payment term can be marked as primary to serve as a fallback. + +## Things to know + +- Payouts represent the actual bank transfers from Shopify. Payment transactions are the individual charges, refunds, and adjustments that compose a payout. +- Payout import captures detailed summaries: charges fee/gross, refunds fee/gross, reserved funds, retried payouts, and adjustments. +- Disputes track status (NeedsResponse, UnderReview, Won, Lost, etc.), type (chargeback, inquiry), and reason (Fraudulent, Unrecognized, etc.) with their own enum types. +- Payment term IDs in `Shpfy Payment Terms` correspond to Shopify's `PaymentTermsTemplate` GIDs -- these are used when constructing draft order mutations. +- Only one payment term can be marked as primary per shop. diff --git a/src/Apps/W1/Shopify/App/src/Products/docs/CLAUDE.md b/src/Apps/W1/Shopify/App/src/Products/docs/CLAUDE.md new file mode 100644 index 0000000000..45f33b8003 --- /dev/null +++ b/src/Apps/W1/Shopify/App/src/Products/docs/CLAUDE.md @@ -0,0 +1,45 @@ +# Products + +Bi-directional product and variant sync between BC Items and Shopify Products. + +## What it does + +Export pushes BC Item data (title, description, vendor, price, variants, images, +tags, metafields, translations) to Shopify. Import pulls Shopify Products back +and either maps them to existing Items or auto-creates new ones via templates. + +## How it works + +Export (`ShpfyProductExport`) iterates products that already have an Item SystemId +link. For each one it re-fills product fields from the Item, compares the record +field-by-field with a snapshot, and only calls the API when something changed. +Variants are matched by Item Variant SystemId and optionally by UoM option slot. +New BC Item Variants that have no Shopify counterpart are created as new Shopify +variants; existing ones are updated. Price-only sync is a separate fast path that +uses bulk GraphQL mutations. + +Import (`ShpfyProductImport`) uses `ShpfyProductMapping` to find a BC Item for +each variant. Mapping is SKU-driven -- the Shop's SKU Mapping setting determines +whether SKU is matched as Item No., Variant Code, Item No.+Variant Code, Barcode, +or Vendor Item No. Unmatched products can auto-create Items via +`ShpfyCreateItem`, which applies an Item Template and creates references. + +## Things to know + +- Product-to-Item linking uses `Item SystemId` (a Guid), not Item No. The + FlowField `Item No.` is derived via CalcFormula. +- The `Has Variants` flag on Product controls whether the connector expects + Item Variant mappings on each Variant. When false, a single variant maps + directly to the Item with no Item Variant required. +- `UoM as Variant` creates a Shopify variant per BC Unit of Measure. The UoM + option slot (1, 2, or 3) is tracked in `UoM Option Id` on the Variant. +- Product.OnDelete invokes `IRemoveProductAction` from the Shop setting -- + implementations archive, draft, or do nothing in Shopify. +- Hash fields (`Image Hash`, `Tags Hash`, `Description Html Hash`) enable + cheap change detection without comparing blob content. +- `ICreateProductStatusValue` determines whether newly created products start + as Active or Draft. +- Max 2048 variants per product -- enforced in `ShpfyCreateProduct`. +- Item attributes marked "As Option" can drive Shopify product options instead + of the default Variant/UoM scheme, with validation for uniqueness and + completeness. diff --git a/src/Apps/W1/Shopify/App/src/Products/docs/business-logic.md b/src/Apps/W1/Shopify/App/src/Products/docs/business-logic.md new file mode 100644 index 0000000000..2bf473394d --- /dev/null +++ b/src/Apps/W1/Shopify/App/src/Products/docs/business-logic.md @@ -0,0 +1,106 @@ +# Products business logic + +Two primary flows: Export (BC to Shopify) and Import (Shopify to BC). + +## Export flow + +`ShpfyProductExport` is the entry point. It filters for products that have an +`Item SystemId` (i.e., already linked to a BC Item), then calls +`UpdateProductData` for each. + +```mermaid +flowchart TD + A[Load ShpfyProduct + BC Item] --> B{Item blocked?} + B -- yes --> C{Shop removal policy?} + C -- StatusToArchived --> D[Skip if already archived] + C -- StatusToDraft --> E[Skip if already draft] + C -- DoNothing --> F[Skip entirely] + B -- no --> G[FillInProductFields from Item] + G --> H{Record changed?} + H -- no --> I[Skip API call] + H -- yes --> J[ProductApi.UpdateProduct] + J --> K[Loop existing variants] + K --> L[Match to ItemVariant + UoM] + L --> M[UpdateProductVariant] + K --> N[Loop BC ItemVariants not in Shopify] + N --> O[CreateProductVariant] +``` + +Change detection in `UpdateProductData` works by snapshotting the product record +before filling fields, then comparing every field via RecordRef. Only when at +least one field differs does it call the API. This avoids burning API quota on +unchanged products. + +Variant handling branches on two axes: whether the item has variants, and whether +`UoM as Variant` is enabled. With UoM-as-variant, the connector searches all three +option slots to find the matching UoM value before deciding create-vs-update. The +logic in `UpdateProductData` is intentionally exhaustive -- it checks Option 1, +then Option 2, then Option 3 for the UoM match. + +Price-only mode (`OnlyUpdatePrice`) skips all non-price fields and attempts a bulk +GraphQL mutation through `ShpfyBulkOperationMgt`. If bulk fails, it falls back to +per-variant API calls and reverts any partially applied changes. + +### Product creation + +When exporting a BC Item that has no Shopify product yet, `ShpfyCreateProduct` +handles initial creation. It builds temp Product and Variant records, +fills prices via `ShpfyProductPriceCalc`, resolves the initial status through +`ICreateProductStatusValue`, and calls `ProductApi.CreateProduct`. Blocked or +sales-blocked item variants are skipped with a logged reason. The 2048-variant +Shopify limit is enforced upfront -- if the expected variant count exceeds it, +the entire item is skipped. + +### Price calculation + +`ShpfyProductPriceCalc` creates a temporary Sales Quote header using the Shop's +posting groups, VAT settings, customer price group, and currency. It then inserts +a temporary Sales Line for the item/variant/UoM combination and reads the +calculated `Unit Price`, `Line Amount`, and `Unit Cost`. If ComparePrice is less +than or equal to Price after calculation, ComparePrice is zeroed out. Events fire +before and after to allow overrides. + +### Image sync + +`ShpfySyncProductImage` supports both directions. Export iterates products, +computes the image hash, and pushes changed images via bulk mutation. Import +downloads images by URL and writes them to the BC Item's Picture field. Variant +images are handled separately -- they update Item Variant pictures when the +variant maps to an Item Variant, or the Item picture when it maps to the Item +itself. + +### Body HTML generation + +`CreateProductBody` in `ShpfyProductExport` assembles the Shopify product +description from three optional sources: extended text lines, marketing text +(Entity Text), and item attributes rendered as an HTML table. Each source is +controlled by a Shop toggle. The result is wrapped in styled divs. + +## Import flow + +`ShpfyProductImport` processes one product at a time. For each variant it calls +`ShpfyProductMapping.FindMapping` to resolve a BC Item. + +```mermaid +flowchart TD + A[For each variant] --> B{FindMapping?} + B -- found --> C{Shopify Can Update Items?} + C -- yes --> D[ShpfyUpdateItem] + C -- no --> E[Skip] + B -- not found --> F{Auto Create Unknown Items?} + F -- yes --> G[ShpfyCreateItem.Run per unmapped variant] + F -- no --> H[Log error on product] +``` + +Mapping in `ShpfyProductMapping.DoFindMapping` is SKU-strategy-driven. Based on +the Shop's SKU Mapping setting, it tries to match the variant's SKU to an Item +No., Vendor Item No., Variant Code, Item No.+Variant Code (split by separator), +or Barcode. If SKU matching fails, it falls back to barcode matching as a last +resort. The `OnBeforeFindProductMapping` event fires before any of this, allowing +complete override. + +Item creation in `ShpfyCreateItem` applies an Item Template (from Shop config or +event override), sets description, prices (converted from shop currency if +needed), vendor, and item category. For `Item No. + Variant Code` and `Variant +Code` SKU mappings, it also creates Item Variants and cross-references +(barcodes, vendor item references). diff --git a/src/Apps/W1/Shopify/App/src/Products/docs/data-model.md b/src/Apps/W1/Shopify/App/src/Products/docs/data-model.md new file mode 100644 index 0000000000..3171d70c76 --- /dev/null +++ b/src/Apps/W1/Shopify/App/src/Products/docs/data-model.md @@ -0,0 +1,61 @@ +# Products data model + +## Entity relationships + +```mermaid +erDiagram + Product ||--o{ Variant : "has" + Variant ||--o| InventoryItem : "tracked by" + Product }o--|| Shop : "belongs to" + Variant }o--o| ItemVariant : "maps to" +``` + +## Product (table 30127) + +The central record linking a Shopify product to a BC Item. The link is through +`Item SystemId`, a Guid pointing at the BC Item's SystemId. A FlowField `Item No.` +resolves this to a human-readable code, but all logic operates on the Guid. The +secondary key `(Shop Code, Item SystemId)` enforces one product per item per shop. + +Hash fields enable cheap change detection. `Description Html Hash`, `Tags Hash`, and +`Image Hash` are integer hashes computed by `Shpfy Hash` and stored alongside the +blob/tag data. Export compares the current hash to the stored one rather than +diffing the full content. `Last Updated by BC` timestamps the most recent export +so the connector can distinguish BC-initiated changes from Shopify-side edits. + +The OnDelete trigger is where product removal policy lives. It reads the Shop's +"Action for Removed Products" setting, resolves it to an `IRemoveProductAction` +implementation, and calls `RemoveProductAction` before cascading deletes to +Variants and Metafields. + +## Variant (table 30129) + +Each Shopify variant belongs to a Product via `Product Id`. It maps to a BC Item +via `Item SystemId` and optionally to an Item Variant via `Item Variant SystemId`. +The `Mapped By Item` flag distinguishes variants that were matched to the item +itself (no variant code) from those matched to a specific Item Variant. + +Shopify allows up to three option name/value pairs per variant. The connector uses +these for two different purposes depending on configuration. When `UoM as Variant` +is on, one option slot holds the Unit of Measure code and `UoM Option Id` (1, 2, +or 3) records which slot it occupies. When item attributes are marked "As Option", +the option slots hold attribute name/value pairs instead. + +The `Image Hash` field tracks the variant-level image separately from the product +image, enabling per-variant image sync. + +## InventoryItem (table 30126) + +A Shopify inventory item, linked to a Variant by `Variant Id`. This is Shopify's +physical-goods record -- it holds country of origin, shipping requirements, and +whether inventory is tracked. There is no direct BC table counterpart; it exists +purely to mirror the Shopify data model. + +## The "Has Variants" gotcha + +When a Product's `Has Variants` is false, the product has a single default variant +in Shopify. The connector maps this variant directly to the BC Item with no Item +Variant needed. When `Has Variants` is true, the connector expects each non-default +Variant to carry an `Item Variant SystemId`. This flag drives branching throughout +export, import, and mapping -- if it gets out of sync with reality, mapping will +silently fail or skip variants. diff --git a/src/Apps/W1/Shopify/App/src/Products/docs/extensibility.md b/src/Apps/W1/Shopify/App/src/Products/docs/extensibility.md new file mode 100644 index 0000000000..015dc7e887 --- /dev/null +++ b/src/Apps/W1/Shopify/App/src/Products/docs/extensibility.md @@ -0,0 +1,84 @@ +# Products extensibility + +All events are defined in `ShpfyProductEvents` (codeunit 30177). The two +interfaces live in the Interfaces/ subfolder. + +## Customizing price calculation + +- `OnBeforeCalculateUnitPrice` -- set `Handled := true` and populate UnitCost, + Price, and ComparePrice yourself to replace the default Sales Line-based + calculation entirely. Receives Item, VariantCode, UoM, Shop, and Catalog. +- `OnAfterCalculateUnitPrice` -- adjust the computed values after the default + calculation runs. Same parameters, but no Handled flag. + +These fire inside `ShpfyProductPriceCalc.CalcPrice` and are the right place +to implement custom pricing tiers, catalog-specific markups, or currency +overrides. + +## Customizing product mapping (import) + +- `OnBeforeFindProductMapping` -- intercept the mapping lookup before the + SKU-based strategy runs. Set Handled to bypass default logic. You receive + the Product, Variant, and output Item/ItemVariant records to populate. + +## Customizing item creation (import) + +- `OnBeforeCreateItem` / `OnAfterCreateItem` -- fire around the creation of + a BC Item from a Shopify product. OnBefore can set Handled to supply your + own item creation logic. OnAfter is for post-creation enrichment (custom + fields, dimensions, etc.). +- `OnBeforeCreateItemVariant` / `OnAfterCreateItemVariant` -- same pattern + for Item Variant creation when the product has variants. +- `OnBeforeCreateItemVariantCode` -- override the auto-generated variant code + (which defaults to the Shop's Variant Prefix + incrementing number). +- `OnBeforeFindItemTemplate` / `OnAfterFindItemTemplate` -- control which + Item Template is used when auto-creating items. + +## Customizing product body HTML (export) + +- `OnBeforeCreateProductBodyHtml` -- completely replace the HTML generation. + Set Handled and write your own ProductBodyHtml. +- `OnAfterCreateProductBodyHtml` -- post-process the generated HTML, e.g., to + append custom sections or strip unwanted content. + +## Customizing barcodes + +- `OnBeforGetBarcode` / `OnAfterGetBarcode` -- override barcode resolution + for a given Item No., Variant Code, and UoM. OnBefore can set Handled; + OnAfter can modify the resolved barcode. + +## Customizing exported product data + +- `OnAfterFillInShopifyProductFields` -- modify the Shopify product record + after standard fields (title, vendor, type, description, tags) are set + from the BC Item. +- `OnAfterFillInProductVariantData` / `OnAfterFillInProductVariantDataFromVariant` + -- modify variant data after standard fields are set. The "FromVariant" + version fires when both ItemVariant and ItemUnitOfMeasure are present. +- `OnBeforSetProductTitle` / `OnAfterSetProductTitle` -- override or adjust + the product title derived from Item Description / Item Translation. +- `OnBeforeSendCreateShopifyProduct` / `OnBeforeSendUpdateShopifyProduct` / + `OnBeforeSendAddShopifyProductVariant` / `OnBeforeSendUpdateShopifyProductVariant` + -- last chance to modify the records before they are serialized to API calls. + +## Customizing item updates (import) + +- `OnBeforeUpdateItem` / `OnAfterUpdateItem` -- fire when Shopify product + data is being written back to an existing BC Item. +- `OnBeforeUpdateItemVariant` / `OnAfterUpdateItemVariant` -- same for Item + Variants. +- `OnDoUpdateItemBeforeModify` / `OnDoUpdateItemVariantBeforeModify` -- fire + just before `Item.Modify()` / `ItemVariant.Modify()`, with an + `IsModifiedByEvent` flag to signal whether your subscriber made changes. + +## Interfaces + +**IRemoveProductAction** -- determines what happens in Shopify when a Product +record is deleted in BC. Implementations: `ShpfyRemoveProductDoNothing`, +`ShpfyToArchivedProduct`, `ShpfyToDraftProduct`. Chosen via the "Action for +Removed Products" enum on the Shop. Not extensible (enum is non-extensible). + +**ICreateProductStatusValue** -- returns the initial Shopify status (Active or +Draft) when creating a new product from a BC Item. Implementations: +`ShpfyCreateProdStatusActive`, `ShpfyCreateProdStatusDraft`. Chosen via the +"Status for Created Products" enum on the Shop. diff --git a/src/Apps/W1/Shopify/App/src/Shipping/docs/CLAUDE.md b/src/Apps/W1/Shopify/App/src/Shipping/docs/CLAUDE.md new file mode 100644 index 0000000000..56e9719335 --- /dev/null +++ b/src/Apps/W1/Shopify/App/src/Shipping/docs/CLAUDE.md @@ -0,0 +1,20 @@ +# Shipping + +Maps BC sales shipments to Shopify fulfillments and syncs shipping method configuration between the two systems. This is the outbound side of shipping -- when BC posts a sales shipment, this module creates the corresponding Shopify fulfillment via GraphQL. + +## How it works + +The `Shpfy Sync Shipm. to Shopify` report drives the process. It finds unsynced Sales Shipment Headers (those with a Shopify Order Id but no Fulfillment Id), fetches the fulfillment orders from Shopify, then calls `ShpfyExportShipments` to build and send `fulfillmentCreate` GraphQL mutations. The mutation is batched by location -- each Shopify location gets its own fulfillment request, with a hard cap of 250 line items per request. + +Tracking information is assembled from the BC Shipping Agent record. The `Shpfy Tracking Company` enum on the Shipping Agent table extension maps BC agents to Shopify-recognized carriers. If the agent has an Internet Address, the tracking URL is resolved from it; otherwise, subscribers can override via `OnBeforeRetrieveTrackingUrl`. The `ShpfyShipmentMethodMapping` table maps Shopify delivery method names to BC Shipment Method Codes, Shipping Agents, and shipping charge G/L accounts or items. + +Shipping charges are imported on the order side by `ShpfyShippingCharges`, which pulls `shippingLines` from the order and populates `Shpfy Order Shipping Charges`. Any new shipping method title seen during import is auto-created as an unmapped entry in the mapping table. + +## Things to know + +- Fulfillments are grouped by Shopify Location Id. If an order spans multiple locations, multiple `fulfillmentCreate` mutations are sent. +- The module auto-accepts pending fulfillment requests for orders assigned to BC's fulfillment service before creating the fulfillment. +- Fulfillment orders from third-party fulfillment services (not BC's own) are skipped -- they cannot be fulfilled by BC. +- The `Shpfy Tracking Company` enum on Shipping Agent controls whether Shopify gets a recognized carrier name or the free-text agent name. +- A Fulfillment Id of -1 on the Sales Shipment Header means the export failed; -2 means no applicable lines. +- `ShpfyShippingEvents` exposes two integration events: `OnBeforeRetrieveTrackingUrl` and `OnGetNotifyCustomer` for extensibility. diff --git a/src/Apps/W1/Shopify/App/src/Transactions/docs/CLAUDE.md b/src/Apps/W1/Shopify/App/src/Transactions/docs/CLAUDE.md new file mode 100644 index 0000000000..f74f627a85 --- /dev/null +++ b/src/Apps/W1/Shopify/App/src/Transactions/docs/CLAUDE.md @@ -0,0 +1,20 @@ +# Transactions + +Tracks individual payment transactions on Shopify orders -- captures, authorizations, refunds, and voids. Maps Shopify payment gateways and credit card companies to BC payment methods, and bridges transaction data into BC Customer Ledger Entries. + +## How it works + +`ShpfyTransactions.UpdateTransactionInfos` fetches all transactions for a given order via the `GetOrderTransactions` GraphQL query and upserts them into the `Shpfy Order Transaction` table. Each transaction record captures the type (Sale, Authorization, Capture, Void, Refund), status, gateway name, credit card details, dual-currency amounts (shop money and presentment money), and rounding amounts. The gateway and credit card company are auto-registered in their respective lookup tables (`Shpfy Transaction Gateway`, `Shpfy Credit Card Company`), and a `Shpfy Payment Method Mapping` entry is auto-created keyed on shop + gateway + credit card company. + +The `Shpfy Suggest Payments` codeunit hooks into BC's general journal posting to stamp Shopify Transaction IDs onto Customer Ledger Entries. This creates a traceable link from Shopify payment captures to BC accounting entries. The `Used` FlowField on the transaction table checks whether a CLE with that transaction ID exists. On journal reversal, the transaction ID is cleared from both the original and reversal entries. + +The `Shpfy Payment Method Mapping` table is the central configuration point -- users map each gateway + credit card company combination to a BC Payment Method Code, which controls how the order is processed in the cash receipt journal. + +## Things to know + +- The `Parent Id` field on `Shpfy Order Transaction` links refund transactions back to their original charge, enabling refund-to-charge tracing. +- Payment method mapping is keyed on shop + gateway + credit card company -- the same gateway can map to different BC payment methods depending on the card brand. +- The `Gift Card Id` is extracted from the transaction's receipt JSON, not from a direct GraphQL field. +- Transaction amounts include both shop money and presentment money, plus separate rounding amounts for each currency. +- The `Manual Payment Gateway` boolean distinguishes manual payment methods (like COD or bank transfer) from automated gateways. +- FlowFields on the transaction table provide quick lookups to Sales Document No. and Posted Invoice No. for the related order. diff --git a/src/Apps/W1/Shopify/App/src/Translations/docs/CLAUDE.md b/src/Apps/W1/Shopify/App/src/Translations/docs/CLAUDE.md new file mode 100644 index 0000000000..8c27325e46 --- /dev/null +++ b/src/Apps/W1/Shopify/App/src/Translations/docs/CLAUDE.md @@ -0,0 +1,19 @@ +# Translations + +Syncs multi-language product data (titles and descriptions) from BC item translations to Shopify's translation API. The module is interface-driven to support extending translation to other resource types. + +## How it works + +The `Shpfy ICreate Translation` interface defines a single method -- `CreateTranslation` -- that accepts a source record, a Shopify language, a temporary translation record set, and a dictionary of translatable content digests. The only implementation today is `ShpfyCreateTranslProduct`, which translates product titles from BC's Item Translation table and product body HTML via `ProductExport.CreateProductBody`, both for a given language code. + +Translations are stored in the `Shpfy Translation` table, keyed by resource type, resource ID, locale, and field name (e.g., "title", "body_html"). The value is a BLOB to handle large HTML descriptions. The table includes change detection -- `AddTranslation` compares the new translation against the stored one and only keeps the record if it has actually changed. Each translation also stores the `Transl. Content Digest` from Shopify, which is required by the translations API to identify which version of the original content the translation applies to. + +`ShpfyTranslationMgt` provides a helper to look up BC Item Translation records by item number, variant code, and language code. The `Shpfy Language` table maps Shopify's shop languages to BC language codes and locales. + +## Things to know + +- The `Shpfy ICreate Translation` interface is the extension point -- new resource types (e.g., collections, metafields) would add new implementations. +- Translation values are stored as BLOBs because product body HTML can exceed normal text field limits. +- Change detection prevents unnecessary API calls -- only translations that differ from the last synced value are included in the update query. +- The `Transl. Content Digest` is Shopify's content fingerprint; it must match the current translatable content or the API will reject the update. +- The `Shpfy Resource Type` enum identifies what kind of Shopify entity is being translated (currently just Product).