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.
Single-product check (check_one/3)
check_one/3 is the single-product counterpart to check/2, meant
for a future per-product admin panel that shouldn't have to pull the
whole catalog through check/2 just to look at one item. It looks up
the ONE local product, reads its Shopify link
(metadata["_shopify"]["product_id"]), fetches that ONE Shopify
product by id (AdminClient.fetch_product/3 — no pagination, no
bulk fetch), and diffs the pair with the exact same
ProductDiff.diff/4 check/2 uses. There is deliberately no
storefront fallback here (unlike check/2's Source.fetch/2): that
fallback exists so a broken Admin token still surfaces SOME diff for
a full catalog sync; for exactly one product a caller is better served
by the real failure ({:error, reason}, straight from
AdminClient.fetch_product/3) than a silently narrower price-only
result.
Precomputed currency verdict, for a caller applying several fields per action
apply_change/3/apply_changes/3 accept opts[:currency_verdict] —
a currency_verdict/1 result the caller already computed. When given,
it is used AS-IS and opts[:admin_options]/the shop lookup are never
consulted for that call; when absent (every call site before this
option existed), behavior is unchanged: currency_verdict/1 is still
computed fresh. This exists for a caller that applies several fields
of the SAME product as separate apply_change/3 calls within one
operator action (e.g. a per-product review panel with a checkbox per
field, "apply" clicked once) — without it, each call pays for its own
live AdminClient.fetch_shop/2 request, apply_changes/3's own
batch of N already avoids the same cost across N products, but the
price of one product's worth of separate field-clicks was still N
Shopify hits. A keyword option (over, say, a table of a -1/wider
apply_change/4) was chosen because it composes with the EXISTING
opts apply_change/3/apply_changes/3 already take, needs no new
arity, and reads at the call site as exactly what it is — an
already-known answer to the same question currency_verdict/1 itself
answers.
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.
Checks ONE local product against its matched Shopify product — see
this module's moduledoc ("Single-product check") for why this exists
alongside check/2 and how the two differ.
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. opts[:currency_verdict] — a currency_verdict/1 result the
caller already computed — skips that lookup entirely and uses it
as-is instead; see this module's moduledoc ("Precomputed currency
verdict") for why and when a caller would pass it.
@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, and opts[:currency_verdict]
the same escape hatch — when given, this skips the lookup entirely
(still once, not per-change either way) and uses it as-is, same as
apply_change/3.
@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.
@spec check_one(String.t(), String.t(), keyword()) :: {:ok, %{ changes: [PhoenixKitEcommerce.Shopify.ProductDiff.Change.t()], source: :admin }} | {:error, :not_found | :not_linked | :invalid_product_id | :not_found_in_shopify | term()}
Checks ONE local product against its matched Shopify product — see
this module's moduledoc ("Single-product check") for why this exists
alongside check/2 and how the two differ.
item_uuid is looked up via Shop.get_product/2 (whichever adapter
ProductSource.current/0 picks, same as everywhere else in this
facade) — no local product at that uuid is {:error, :not_found}.
Its Shopify link is read from product.metadata["_shopify"] ["product_id"], the same sub-map ProductDiff's own handle-matching
reads ["handle"] from (see that module's moduledoc); a product with
no such link is {:error, :not_linked}. There is no slug/handle
fallback the way check/2's bulk diff has — a single product with no
known Shopify id has nothing to fetch by id in the first place.
The linked id is then fetched with AdminClient.fetch_product/3; a
404 there (:not_found — the id no longer exists in Shopify) is
translated to {:error, :not_found_in_shopify} so it isn't confused
with the LOCAL lookup's own :not_found above. Any other
AdminClient.fetch_product/3 error (:unauthorized, :rate_limited,
a transport error, …) is returned as-is.
The single local/remote pair is then diffed with the same
ProductDiff.diff/4 check/2 uses, over every comparable field
(ProductDiff.comparable_fields/0 — the Admin API, the only source
here, always carries all of them). diff/4 still matches by handle
(product.metadata["_shopify"]["handle"] vs.
shopify_product["handle"]) — a product renamed on Shopify's side is
fetched correctly (by id) but won't match its own now-stale local
handle, and reports no changes rather than the rename itself; this is
inherited from reusing diff/4 unchanged, not special-cased here.
Returns {:ok, %{changes: [Change.t()], source: :admin}} —
:changes is [], never omitted, when nothing differs. :source is
always :admin (this function has no storefront fallback — see the
moduledoc) and exists only so the result shares check/2's own
:source key, letting a future panel reuse rendering code without
branching on which check produced it; deliberately NOT carrying
check/2's other keys (:new_products, :fallback_reason,
:total_shopify_products, :matched_local_products) since none of
them mean anything for a single product. Every Change in :changes
is a regular (non-create?) Change, so it can be handed straight to
apply_change/3/apply_changes/3 exactly like one from check/2.
opts[:base_locale] and opts[:admin_options] are the same options
check/2 takes, forwarded the same way.
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.