PhoenixKitEcommerce.Shopify.Sync (PhoenixKitEcommerce v0.4.2)

Copy Markdown View Source

Orchestrates a Shopify → local product sync: fetch, diff, and selectively apply confirmed changes.

This is the domain layer only — no LiveView/UI wiring here (see PhoenixKitEcommerce.Shopify.Provider moduledoc for why "Test Connection" doesn't exercise this; that's this module's job instead, via check/2).

Summary

Functions

Applies fields from change to its product.

Applies fields to every change in changes, partitioning them by outcome.

Fetches Shopify products for integration_uuid via Source.fetch/2 (Admin API primary, public storefront fallback — see that module's moduledoc for the fallback/abort rules), diffs them against the local catalog, and returns the changes an operator can review.

Functions

apply_change(change, fields \\ :all)

@spec apply_change(
  PhoenixKitEcommerce.Shopify.ProductDiff.Change.t(),
  :all | [atom()]
) ::
  {:ok, PhoenixKitEcommerce.Product.t()} | {:error, Ecto.Changeset.t()}

Applies fields from change to its product.

fields is :all (every field in change.changes) or an explicit list of field atoms — only fields present in BOTH change.changes and fields are written, everything else on the product is left untouched. Localized fields (title, body_html, description) are merged into change.base_locale only (the locale ProductDiff.diff/4 matched and compared this change against) — other languages already on the product are preserved. This is why base_locale lives on the Change struct rather than being re-read here: a change diffed against one locale must be applied into that same locale, not whatever the default happens to be at apply time.

apply_changes(changes, fields \\ :all)

Applies fields to every change in changes, partitioning them by outcome.

A changeset failure on one product doesn't stop the rest from being attempted. Returns %{succeeded: [Change.t()], failed: [Change.t()]}, each preserving the input order — so a caller can drop succeeded and keep offering failed for retry instead of reporting them as done.

check(integration_uuid, opts \\ [])

@spec check(String.t(), keyword()) ::
  {:ok,
   %{
     changes: [PhoenixKitEcommerce.Shopify.ProductDiff.Change.t()],
     source: :admin | :storefront,
     fallback_reason: term() | nil,
     total_shopify_products: non_neg_integer(),
     matched_local_products: non_neg_integer()
   }}
  | {:error, term()}

Fetches Shopify products for integration_uuid via Source.fetch/2 (Admin API primary, public storefront fallback — see that module's moduledoc for the fallback/abort rules), diffs them against the local catalog, and returns the changes an operator can review.

The result carries :source (:admin or :storefront) and :fallback_reason alongside :changes — a caller MUST branch on :source before presenting the result as a complete diff. A :storefront result only ever contains price changes (see Source/StorefrontClient), because the Admin API rejected the connection's credentials; :fallback_reason carries why (e.g. :unauthorized). Treating it as a full diff would report "no changes" for text fields that were never actually compared.

:total_shopify_products is the raw count of products Source.fetch/2 returned, BEFORE matching against the local catalog — i.e. it includes Shopify products with no local counterpart, which never appear in :changes (see this module's own moduledoc: matching a product with no local counterpart is the CSV importer's job, not this sync's).

:matched_local_products is ProductDiff.matched_count/3 on the same input — how many local products a Shopify handle actually matched, independent of whether that match has any field difference (a product identical to its Shopify counterpart is matched but contributes no Change). :total_shopify_products and :matched_local_products together are what "coverage" actually means: matched / Shopify total. Neither length(:changes) (which undercounts — a matched, identical product isn't a change) nor the local catalog's total size (which overcounts — a local product with no Shopify counterpart at all still isn't part of what this check could ever see) is that number. Both fields are additive — they do not change :changes'/:source's meaning, and :total_shopify_products on its own says nothing about coverage without :matched_local_products alongside it.

On the :storefront fallback path, :total_shopify_products counts only products the public storefront serves (published to the Online Store) — a narrower population than the Admin API's full catalog, and not comparable to it. A caller computing a coverage percentage from these two fields MUST do so only when :source == :admin.

opts[:base_locale] is the locale read for matching/diffing localized fields, defaulting to Translations.default_language/0 — pass it explicitly to keep a call free of that default's database access (e.g. in tests), same reason ProductDiff.diff/4 takes it. The rest of opts (:admin_options, :storefront_options) is forwarded to Source.fetch/2.