PhoenixKitEcommerce.Shopify.Sync (PhoenixKitEcommerce v0.5.0)

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).

Catalogue source (Block 3, "sync 6a")

When ProductSource.current/0 is Catalogue, apply_change/2 writes through PhoenixKitEcommerce.Catalogue.Writer into the matched catalogue item instead of Shop.update_product/2 (which refuses a view-struct outright — see Product/update_product/2's own moduledoc), and check/2 additionally surfaces unmatched Shopify handles as create-Changes (ProductDiff.new_product_changes/3) under :new_products, which apply_change/2/apply_changes/2 dispatch to Writer.create_from_shopify/2. Under the legacy source :new_products is always [] — creating products there stays the CSV importer's job.

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() | term()}

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.

A create-Change (create?: true, from check/2's :new_products — catalogue source only) ignores fields entirely: there is no existing product to apply a subset of fields onto, so the whole change.shopify_product payload goes to Writer.create_from_shopify/2.

Under the legacy source, a regular Change still writes through Shop.update_product/2, unchanged from before. Under the catalogue source, it writes through PhoenixKitEcommerce.Catalogue.Writer. update_from_shopify/3 into the matched item instead — change. product_uuid is the item's own uuid (see ProductSource.Catalogue. View.product_view/2: a catalogue view-struct's :uuid IS the item's), so it's fetched with PhoenixKitCatalogue.Catalogue.get_item!/1, not Shop.get_product!/1 (which would hand back a read-only view-struct Shop.update_product/2 refuses).

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()],
     new_products: [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.