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.
Currency guard (per-domain-currency design §7.5)
apply_change/3/apply_changes/3 compare the connected Shopify
store's OWN currency (AdminClient.fetch_shop/2) against the shop's
base currency before writing any PRICE field (:price,
:compare_at_price) — never before writing anything else. On a
mismatch the price fields are dropped from what gets applied (an
otherwise-eligible non-price field on the SAME change, e.g. :title,
still gets written) and a Logger.warning/1 names both currencies; a
change whose ONLY requested fields were price fields returns
{:error, {:currency_mismatch, shop_currency, base_currency}} instead
of a silent no-op success.
A create-Change (create?: true) is guarded too, but ALL-OR-NOTHING
rather than field-by-field: Writer.create_from_shopify/2 writes a
price unconditionally (there is no fields/changes to filter it out
of), so on a mismatch the whole create is refused —
{:error, {:currency_mismatch, shop_currency, base_currency}}, item
never created — rather than creating it without a price or with a
wrong one. This is the WORSE case, not a safer one: an update at
least leaves an existing, correct price alone; a create would mint a
brand-new record whose price is wrong from the moment it exists,
labelled with the base currency by Writer itself (§4.6), with no
prior value anywhere to reveal the error.
The shop lookup itself happens ONCE per apply_changes/3 call (a
batch of N products, one lookup, not N — see currency_verdict/1),
not once per apply_change/3 (a single call is its own batch of one).
A lookup failure (no Shopify connection, network, bad credentials)
never blocks a sync that was otherwise working: it logs one
Logger.warning/1 and proceeds exactly as if the guard were absent —
see currency_verdict/1's own doc for why this fails in the
OTHER direction from the mismatch case above.
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.
Looks up the connected Shopify store's own currency and compares it
against the base currency (§7.5) — public so Workers.ShopifyMediaSyncWorker
(the variants/prices media sync, a second price-writing path that
bypasses apply_change/3/apply_changes/3 entirely) can reuse this
exact lookup and fail-open policy instead of re-implementing it.
Functions
@spec apply_change( PhoenixKitEcommerce.Shopify.ProductDiff.Change.t(), :all | [atom()], keyword() ) :: {: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).
Before writing, :price/:compare_at_price are subject to the
currency guard (this module's moduledoc, §7.5): on a shop-currency
mismatch they are dropped from fields_to_apply (a non-price field on
the same change still gets written) and, if that was the only thing
fields asked for, this returns {:error, {:currency_mismatch, shop_currency, base_currency}} instead of a silent no-op success. A
create-Change is subject to the same guard too, but all-or-nothing —
see the moduledoc for why a create can't be partially refused the way
an update can.
opts[:admin_options] forwards to AdminClient.fetch_shop/2 for the
currency-guard lookup (e.g. req_options: to stub the transport in
tests) — the same option name check/2 already uses for its own Admin
API call.
@spec apply_changes( [PhoenixKitEcommerce.Shopify.ProductDiff.Change.t()], :all | [atom()], keyword() ) :: %{ succeeded: [PhoenixKitEcommerce.Shopify.ProductDiff.Change.t()], failed: [PhoenixKitEcommerce.Shopify.ProductDiff.Change.t()] }
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.
The currency guard's shop lookup (this module's moduledoc, §7.5) runs
ONCE for the whole batch, not once per change in changes — see
currency_verdict/1. opts[:admin_options] is the same option
apply_change/3 takes for that lookup.
@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.
Looks up the connected Shopify store's own currency and compares it
against the base currency (§7.5) — public so Workers.ShopifyMediaSyncWorker
(the variants/prices media sync, a second price-writing path that
bypasses apply_change/3/apply_changes/3 entirely) can reuse this
exact lookup and fail-open policy instead of re-implementing it.
:match covers three cases identically, on purpose: currencies
actually agree, no Shopify connection exists at all (nothing to
mismatch against), and the lookup failed (fail OPEN — an unreachable
Shopify must never stop a sync that was otherwise working, logged once
as a warning). opts[:admin_options] forwards to AdminClient.fetch_shop/2
(e.g. req_options: to stub the transport in tests).
Re-resolves the connected Shopify integration on every call rather
than accepting one as a parameter — safe only because this codebase
supports exactly one Shopify connection everywhere (Provider,
Shop.list_connections("shopify", owner: :system) elsewhere all make
the same assumption); a caller batching many writes still gets the
ONE-lookup-per-batch behaviour described above, but a caller with more
than one connection to compare against would need a different
function, not another argument to this one.