# `PhoenixKitEcommerce.Shopify.ProductDiff`
[🔗](https://github.com/BeamLabEU/phoenix_kit_ecommerce/blob/0.5.10/lib/phoenix_kit_ecommerce/shopify/product_diff.ex#L1)

Compares local products against Shopify Admin API product data.

Matching is by handle: for a catalogue-backed view-struct
(`metadata["_shopify"]["handle"]`, written by
`ProductSource.Catalogue.View`) that handle is used directly; for a
legacy product (no such metadata) matching falls back to
`product.slug[base_locale] == shopify_product["handle"]`, unchanged.
A Shopify product with no local match is skipped by `diff/4` — this
module never creates products from there (product creation stays the
CSV importer's job under the legacy source; under the catalogue
source, `new_product_changes/3` below surfaces those same unmatched
handles as create-`Change`s for `Shopify.Sync` instead).

Compared fields: `title`, `body_html`, `description`, `vendor`, `tags`,
`status`, `price`, `compare_at_price`. `title`/`body_html`/`description`
are localized fields; only the base locale is read/compared here
(writing them back is
`Shopify.Sync.apply_change/2`'s job). `diff/4`'s `opts[:only]` narrows this
set to a chosen list of fields — needed because some Shopify data sources
(e.g. a public storefront fallback) only ever carry a subset of fields,
such as price alone. Comparing an absent field against a present local
value would otherwise be reported as a deletion, and applying such a
change would erase real data.

`body_html` is normalized through `HtmlToMarkdown.convert/1` BEFORE it is
compared or stored as `incoming` — Shopify's API always returns raw HTML,
but the locally stored value is Markdown (the storefront renders it
through a Markdown component, and raw HTML embeds any Markdown a seller
already hand-wrote instead of rendering it). Comparing raw-HTML-incoming
against Markdown-current directly would report every synced product as
"changed" on every single check, forever, since the two sides can never
be byte-equal even when the content is identical. Converting first means
both sides really are the same format, so a real no-op compares as one;
`description` still runs `HtmlText.extract_description/1` against the
ORIGINAL raw `body_html`, unconverted — it strips tags for a plain-text
summary and must not pick up Markdown syntax as literal characters.

`diff/4` (and its `diff/2`/`diff/3` arities) is pure — no network or
database access — so it can be tested directly with in-memory product
structs and Shopify API response maps.

# `comparable_fields`

```elixir
@spec comparable_fields() :: [atom()]
```

The full set of fields `diff/4` can compare — its `opts[:only]` default,
and what a caller (e.g. `PhoenixKitEcommerce.Shopify.Source`) should pass
for a source that carries every field, such as the Admin API. There is no
`:all` sentinel accepted by `opts[:only]` — passing this list explicitly
is the correct way to ask for "everything".

# `diff`

```elixir
@spec diff([PhoenixKitEcommerce.Product.t()], [map()], String.t(), keyword()) :: [
  PhoenixKitEcommerce.Shopify.ProductDiff.Change.t()
]
```

Matches `shopify_products` to `local_products` by handle and returns one
`Change` per match that has at least one real difference. Shopify
products without a matching local product are skipped.

`base_locale` defaults to `Translations.default_language/0`, which reads
through `PhoenixKit.Settings` — pass it explicitly to keep a call free of
database access (e.g. in tests).

`opts[:only]` restricts the comparison to the given list of field atoms
(a subset of [:title, :body_html, :description, :vendor, :tags, :status, :price, :compare_at_price]), defaulting to all of them.
Pass it when the incoming Shopify data only ever carries some fields —
e.g. a public-storefront fallback source that reads price alone — so the
fields it doesn't carry aren't reported (and later applied) as deletions.
An unknown key in `opts` raises, so a typo (e.g. `onlyy:`) can't silently
fall back to comparing every field. Every element of `opts[:only]` itself
is validated against [:title, :body_html, :description, :vendor, :tags, :status, :price, :compare_at_price] too — a typo'd field
atom there (`only: [:titel]`) fails the *opposite* way a missing `:only`
does: instead of comparing too much, it would compare nothing and report
a catalog "in sync" that was never actually checked, which is the worse
failure mode for a sync tool. `only: []` (compare nothing, deliberately)
is not an error — every element of an empty list is vacuously valid.

`base_locale` must be a string. This guards against the easy mistake of
passing `opts` as the third argument and dropping `base_locale` entirely
(`diff(local, shopify, only: [:price])`) — without the guard that silently
matches nothing and returns `[]`, instead of raising.

# `matched_count`

```elixir
@spec matched_count([PhoenixKitEcommerce.Product.t()], [map()], String.t()) ::
  non_neg_integer()
```

Counts distinct `local_products` matched by handle to a
`shopify_products` entry — the same matching rule `diff/4` uses
(`product.slug[base_locale] == shopify_product["handle"]`), but
independent of whether the match has any actual field difference.

This is "how much of the Shopify catalog a sync can even see": every
local product with no matching Shopify handle is invisible to `diff/4`
regardless of `:only` (see this module's moduledoc — a Shopify product
with no local match is skipped, and the reverse is equally true: a
local product with no Shopify-side handle never reaches `build_change/4`
at all). Reuses `diff/4`'s own `index_by_handle/2`, so this can never
drift from what `diff/4` actually matches.

`base_locale` defaults to `Translations.default_language/0`, same as
`diff/4` — pass it explicitly to keep a call free of that default's
database access.

# `new_product_changes`

```elixir
@spec new_product_changes([PhoenixKitEcommerce.Product.t()], [map()], String.t()) :: [
  PhoenixKitEcommerce.Shopify.ProductDiff.Change.t()
]
```

Shopify products with no matching local product, as create-`Change`s
(`create?: true`, `product_uuid: nil`, `shopify_product: <raw payload>`,
`changes: %{}`) — the mirror image of `diff/4`'s own skip rule (see this
module's moduledoc). Meant for the catalogue source only, where a new
Shopify handle becomes a new catalogue item via
`PhoenixKitEcommerce.Catalogue.Writer.create_from_shopify/2`; the legacy
source has no create path here at all (product creation stays the CSV
importer's job there), so a caller must not call this under the legacy
source.

`base_locale` defaults to `Translations.default_language/0`, same as
`diff/4` — pass it explicitly to keep a call free of that default's
database access.

---

*Consult [api-reference.md](api-reference.md) for complete listing*
