PhoenixKitEcommerce.Shopify.Sync (PhoenixKitEcommerce v0.5.6)

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.

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

apply_change(change, fields \\ :all, opts \\ [])

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

apply_changes(changes, fields \\ :all, opts \\ [])

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.

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.

check_one(integration_uuid, item_uuid, opts \\ [])

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

currency_verdict(opts \\ [])

@spec currency_verdict(keyword()) :: :match | {:mismatch, String.t(), String.t()}

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.