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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
60 changes: 60 additions & 0 deletions src/Apps/W1/Shopify/App/CLAUDE.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
# Shopify Connector

Bidirectional sync between Shopify stores and Business Central. Imports orders, customers, and companies from Shopify; exports products, inventory, and shipments to Shopify. All API communication uses Shopify's GraphQL Admin API, not REST. The connector supports multiple Shopify stores per BC company, each configured independently through the Shpfy Shop table (30102).

## Quick reference

- **ID range**: 30100--30460
- **Dependencies**: none (zero entries in `app.json`)

## How it works

Everything flows through the Shop record. Each Shop represents one Shopify store connection and carries 60+ configuration fields that control sync direction, customer mapping strategy, product status rules, inventory calculation method, return/refund processing, and more. The Shop is the god object -- nearly every codeunit takes a Shop record as its entry point, and most design decisions are Shop-level settings rather than per-entity configuration.

The sync model is asymmetric by design. Products flow primarily BC-to-Shopify (items are the source of truth), while orders flow Shopify-to-BC (Shopify is the order capture system). Customer sync supports both directions but import is automatic while export requires manual "Add to Shopify" actions. Inventory is export-only: BC calculates stock using a pluggable strategy interface and pushes adjustments to Shopify locations.

All Shopify API calls are GraphQL, encapsulated through the `Shpfy IGraphQL` interface. The `ShpfyGraphQLType` enum (30111) defines 143 query types, each implemented by a dedicated `Shpfy GQL *` codeunit. The `ShpfyGraphQLQueries` codeunit (30154) resolves enum values to query text via interface dispatch, with template parameter replacement (`{{param}}`). This design means you can override any GraphQL query by subscribing to events on `ShpfyGraphQLQueries` without touching the original codeunit.

The connector does not attempt real-time sync. It uses batch pulls (cursor-based pagination over GraphQL connections) and optional webhooks for order notifications. There is no message queue, no retry infrastructure beyond the standard BC job queue, and no conflict resolution beyond hash-based detection that flags orders for manual review.

## Structure

- `src/Base/` -- Shop table, installer, shared enums, cue/activities for role centers
- `src/GraphQL/` -- IGraphQL interface, 143 GQL codeunits (one per query), rate limiter
- `src/Products/` -- Product/Variant tables, import/export codeunits, price calculation
- `src/Order handling/` -- Order header/line tables, import, mapping, create sales document
- `src/Order Fulfillments/` -- Fulfillment orders (modern per-location API) and actual shipments
- `src/Order Returns/` and `src/Order Refunds/` -- Separate return and refund models
- `src/Order Return Refund Processing/` -- IReturnRefundProcess interface and strategy implementations
- `src/Customers/` -- Customer table, mapping interfaces (email/phone, bill-to, default)
- `src/Companies/` -- B2B company and company location tables, separate mapping strategies
- `src/Inventory/` -- Shop locations, stock calculation interfaces, inventory sync
- `src/Transactions/` -- Payment transactions and gateway mappings
- `src/Payments/` -- Payouts for bank reconciliation
- `src/Gift Cards/` -- Gift card tracking (product when sold, payment method when redeemed)
- `src/Metafields/` -- Typed custom fields with namespace and owner-type polymorphism
- `src/Document Links/` -- N:M junction between Shopify documents and BC documents
- `src/Logs/` -- Data capture (raw JSON), skipped records, communication events
- `src/Bulk Operations/` -- Bulk mutation support for high-volume price updates
- `src/Catalogs/` -- B2B catalog and market-specific pricing
- `src/Webhooks/` -- Webhook subscription management for order notifications

## 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

- Products link to BC Items via `Item SystemId` (a Guid), not Item No. This survives item renumbering. The `Item No.` field on the Product table is a FlowField that looks up through SystemId.
- The Variant table (30129) has three pairs of option name/value fields (`Option 1 Name`/`Option 1 Value` through 3). Shopify's 3-option limit is a hard constraint reflected in the schema.
- Order import always creates the header first, then retrieves and inserts lines, related records (tax, shipping, transactions, fulfillment orders, returns, refunds), and finally adjusts line quantities for refunds. The sequence matters because refund line quantities are subtracted from order line quantities in place.
- The `Line Items Redundancy Code` on the order header is a hash of concatenated line IDs. It is used for conflict detection when re-importing an already-processed order -- if the hash changes, the order is flagged as conflicting.
- Customer mapping uses interface dispatch: the Shop's `Customer Mapping Type` enum selects which `ICustomerMapping` implementation runs. But there is a fallback -- if both Name and Name2 are empty, it always falls back to "By EMail/Phone" regardless of the setting.
- `Shpfy Data Capture` (30114) stores raw JSON blobs linked to any record via table ID and SystemId. This is the primary debugging tool -- when something goes wrong with an import, look at the captured JSON.
- The `Shpfy Skipped Record` table (30159) logs every record the connector deliberately skipped during sync, with a reason. Check this before assuming a sync bug.
- Negative IDs on `Shpfy Customer Address` indicate addresses created by BC (not imported from Shopify). This convention prevents ID collisions.
- The `Return and Refund Process` field on the Shop controls whether returns and refunds are imported at all, and whether credit memos are auto-created. The `IReturnRefundProcess` interface routes to different implementations based on this enum.
- Bulk operations (used for price-only sync) attempt to use Shopify's bulk mutation API first, then fall back to individual GraphQL calls if the bulk operation fails.
124 changes: 124 additions & 0 deletions src/Apps/W1/Shopify/App/docs/business-logic.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,124 @@
# Business logic

## Product sync

Product sync is direction-dependent, controlled by the Shop's `Sync Item` option (To Shopify / From Shopify). The `ShpfySyncProducts.Codeunit.al` entry point dispatches to either `ShpfyProductExport` or `ShpfyProductImport` based on this setting. There is also a price-only mode (`SetOnlySyncPriceOn`) that skips all non-price fields and uses bulk operations for efficiency.

### Export (BC to Shopify)

The export flow in `ShpfyProductExport.Codeunit.al` starts by filtering `Shpfy Product` records that have a non-empty `Item SystemId` and belong to the current shop. The `OnAfterProductsToSynchronizeFiltersSet` event fires here, allowing subscribers to add additional filters.

For each product, the flow calls `UpdateProductData`, which loads the linked BC Item, calls `FillInProductFields` to populate the temp product record (title from item translation or description, vendor from vendor table, product type from item category, HTML body from extended text + marketing text + attributes), then compares hashes to detect changes.

The blocked-item handling is worth noting: when an Item is blocked, the product's status is set according to the Shop's `Action for Removed Products` setting (archive or draft). The `IRemoveProductAction` interface handles the status transition, and the `ICreateProductStatusValue` interface determines the initial status when creating new products.

Variant export iterates all Item Variants plus, if `UoM as Variant` is enabled, all Item Units of Measure. Each variant gets a price calculation, SKU mapping (configurable: barcode, item no., vendor item no.), barcode lookup, and weight computation. Blocked or sales-blocked variants are skipped and logged to the Skipped Records table.

Item attributes mapped as product options occupy variant option slots. Since Shopify caps at 3 options and UoM-as-variant consumes one slot, attributes are limited to 2 (or 3 with UoM disabled). The `VerifyNoItemAttributesAsOptions` check in the Shop table enforces this constraint.

```mermaid
flowchart TD
A[Filter products with Item SystemId] --> B{Can Update Shopify Products?}
B -- No --> Z[Skip]
B -- Yes --> C[Load BC Item]
C --> D{Item Blocked?}
D -- Yes --> E[Set status per Action for Removed Products]
D -- No --> F[FillInProductFields]
F --> G[Compare hashes -- image, tags, description]
G --> H{Changed?}
H -- No --> Z
H -- Yes --> I[Update product via GraphQL]
I --> J[Process variants]
J --> K{Variant blocked?}
K -- Yes --> L[Log to Skipped Records]
K -- No --> M[Fill variant data + price calc]
M --> N[Create or update variant via GraphQL]
N --> O[Sync metafields and translations]
```

### Import (Shopify to BC)

Import in `ShpfySyncProducts.Codeunit.al` retrieves all product IDs from Shopify via `ProductApi.RetrieveShopifyProductIds`, then compares `Updated At` timestamps against local records. Products where the Shopify timestamp is newer than both `Updated At` and `Last Updated by BC` are queued into a temp table. Products already up-to-date are silently skipped.

For each changed product, `ShpfyProductImport` runs in its own commit scope. If it fails, the error is captured and the loop continues with the next product. This per-record isolation prevents one bad product from blocking the entire sync.

## Order import

Order import is the connector's most complex flow, orchestrated by `ShpfyImportOrder.Codeunit.al`. The process handles first-time import, re-import of changed orders, and conflict detection for already-processed orders.

```mermaid
flowchart TD
A[Receive Order ID] --> B[EnsureOrderHeaderExists]
B --> C[Retrieve order JSON via GraphQL]
C --> D[Retrieve and parse order lines]
D --> E{Already processed in BC?}
E -- Yes --> F{Hash/qty/shipping changed?}
F -- Yes --> G[Flag as conflicting]
F -- No --> H[Continue import]
E -- No --> H
H --> I[SetOrderHeaderValuesFromJson -- 50+ fields]
I --> J[SetAndCreateRelatedRecords]
J --> J1[Tax lines]
J --> J2[Shipping charges]
J --> J3[Transactions]
J --> J4[Fulfillment orders]
J --> J5[Returns -- if IReturnRefundProcess says so]
J --> J6[Refunds -- if IReturnRefundProcess says so]
J --> K[Insert order lines + data capture]
K --> L[Compute Line Items Redundancy Code hash]
L --> M[ConsiderRefundsInQuantityAndAmounts]
M --> N[Delete zero-quantity lines]
N --> O{All lines fulfilled + closed?}
O -- Yes --> P[Close order via GraphQL]
O -- No --> Q[Check if Shopify invoice exists]
Q -- Yes --> R[Mark as processed]
Q -- No --> S[Order ready for mapping]
```

The conflict detection in `IsImportedOrderConflictingExistingOrder` checks three conditions: (1) whether `currentSubtotalLineItemsQuantity` increased, (2) whether the line items redundancy hash changed, and (3) whether the shipping charges amount changed. Any mismatch sets `Has Order State Error` and `Has Error` on the header, blocking auto-processing until manually resolved.

The refund adjustment step (`ConsiderRefundsInQuantityAndAmounts`) is particularly important. It iterates all order lines, sums refund line quantities for each, and subtracts them directly from the order line's `Quantity`. It also adjusts the header's total amounts. This means the order lines in BC reflect net quantities after refunds, not the original Shopify quantities. Lines reduced to zero are deleted entirely.

## Order mapping and creation

After import, `ShpfyOrderMapping.Codeunit.al` maps the Shopify order to BC entities. The mapping splits based on B2B status:

- **B2C orders** use customer mapping through the `ICustomerMapping` interface, selected by the Shop's `Customer Mapping Type`. The fallback logic is notable: if both `Name` and `Name2` on the order are empty, the mapper always uses "By EMail/Phone" regardless of the configured strategy.
- **B2B orders** use company mapping through `ICompanyMapping`, routing through the Shop's company settings.

For each order line, `MapVariant` resolves the Shopify variant to a BC Item + Item Variant. Tip lines check that `Tip Account` is configured, gift card lines check `Sold Gift Card Account`.

Once mapping succeeds, `ShpfyCreateSalesDocHeader.Codeunit.al` creates the actual Sales Order (or Sales Invoice, depending on configuration). The `Auto Release Sales Orders` shop setting controls whether the document is immediately released.

## Customer sync

Customer import in `ShpfySyncCustomers.Codeunit.al` retrieves customer IDs from Shopify via GraphQL, then processes each through `ShpfyCustomerImport`. The mapping in `ShpfyCustomerMapping.Codeunit.al` uses a two-phase approach:

1. **FindMapping** checks if the Shopify customer already has a `Customer SystemId`. If the linked BC customer no longer exists, it clears the link and proceeds to step 2.
2. **DoFindMapping** searches BC customers by email (case-insensitive filter using `@` prefix) and phone number (digits-only fuzzy matching via `CreatePhoneFilter`). The phone matching extracts only digits from both the Shopify phone and BC phone fields, handling format differences like +1 (555) 123-4567 vs 5551234567.

Customer export is not automatic. The old `Export Customer To Shopify` boolean was removed in v27 (the field definition is still guarded by `#if not CLEANSCHEMA27` in the Shop table). The replacement is a manual "Add to Shopify" action on the Shopify Customers page.

## Inventory sync

Inventory sync in the Inventory folder is export-only: it calculates BC stock and pushes adjustments to Shopify. For each Shop Location, the connector applies the location's `Location Filter` to restrict which BC locations contribute to the stock calculation, then calls the configured stock calculation interface.

The stock calculation is a three-layer interface hierarchy. `Shpfy IStock Available` answers a boolean "can this type have stock?" question. `Shpfy Stock Calculation` provides the basic `GetStock(Item)` method. `Shpfy Extended Stock Calculation` extends it with `GetStock(Item, ShopLocation)` for location-aware calculations. The Shop's stock calculation enum selects the active implementation.

The connector sends stock adjustments (deltas) to Shopify via the `ModifyInventory` GraphQL mutation, not absolute values. It compares the calculated BC stock against the `Shopify Stock` field on `Shpfy Shop Inventory` to determine the delta.

## Webhooks

The connector supports webhooks for real-time order notifications, managed by `ShpfyWebhooksMgt.Codeunit.al`. When the `Order Created Webhooks` setting is enabled on the Shop, the connector registers a webhook subscription with Shopify. Incoming webhook payloads trigger `ShpfyWebhookNotification.Codeunit.al`, which queues the order for import.

Webhook subscriptions are cleaned up when the shop is disabled (the `Enabled` field's OnValidate trigger calls `WebhooksMgt.DisableBulkOperationsWebhook`).

## Return and refund processing

The `Return and Refund Process` enum on the Shop controls the behavior through the `IReturnRefundProcess` interface. The interface has three key methods:

- `IsImportNeededFor(SourceDocumentType)` -- whether to import returns/refunds at all
- `CanCreateSalesDocumentFor(SourceDocumentType, SourceDocumentId)` -- whether a credit memo can be created
- `CreateSalesDocument(SourceDocumentType, SourceDocumentId)` -- creates the actual credit memo

The "Import Only" setting imports returns and refunds for visibility but does not create credit memos. "Auto Create Credit Memo" requires `Auto Create Orders` to be enabled (enforced by validation on both fields). This two-field coupling is a common source of configuration errors.
Loading
Loading