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

Picks where a sync reads Shopify product data from: the Admin API
(primary, full-fidelity) or the public storefront JSON endpoint
(fallback, price-only — see `PhoenixKitEcommerce.Shopify.StorefrontClient`
for why it is deliberately narrow).

Split into a pure decision core (`decide/2`) and a thin I/O shell
(`fetch/2`). That split is not, on its own, a testability necessity in
this repo — `test/test_helper.exs` excludes `DataCase` tests only when
no test database is reachable, and one normally is (`createdb
phoenix_kit_ecommerce_test`; see that file for details). It earns its
place anyway: the fallback/abort *decision* — which is the one property
worth being certain about, since getting it wrong either silently
narrows a sync to price-only or leaves it stuck on a dead token — is
fully expressible without a domain lookup, an HTTP call, or a database,
so keeping it in a pure function makes every branch a plain data-in,
data-out assertion instead of an HTTP-stub scenario.

Falling back to the storefront is only correct when the Admin API
failure is about the *credential* — a token that's missing, wrong, or
no longer authorized. Anything else (a rate limit, a 5xx, a timeout) is
a transient problem with the Admin API itself; falling back on those
would silently narrow the sync to price-only and report "no text
changes" when the truth is "we could not check". Those abort instead.

# `result`

```elixir
@type result() :: %{
  source: :admin | :storefront,
  products: [map()],
  only: [atom()],
  fallback_reason: term() | nil
}
```

# `decide`

```elixir
@spec decide({:ok, [map()]} | {:error, term()}, {:ok, String.t()} | {:error, term()}) ::
  {:use_admin, [map()], [atom()]}
  | {:use_storefront, String.t(), term(), [atom()]}
  | {:abort, term()}
```

Decides how to source products, given the Admin API's fetch result and
an (already looked-up) shop domain, and what field set the resulting
products carry.

Pure — no I/O. Rules:

  * Admin succeeded → `{:use_admin, products, only}`, `only` being
    `ProductDiff.comparable_fields/0` — the Admin API carries every
    field.
  * Admin failed with a credential error (`:unauthorized`,
    `:missing_credentials`, `:forbidden`) and a shop domain is
    available → `{:use_storefront, domain, reason, only}` — `reason`
    is the Admin failure that triggered the fallback, `only` is
    `[:price]`.
  * Admin failed with a credential error but no shop domain is
    available → `{:abort, reason}` — there is nothing to fall back to.
  * Admin failed with anything else (rate limited, 5xx, timeout, ...)
    → `{:abort, reason}`, regardless of whether a domain is available.

# `fetch`

```elixir
@spec fetch(String.t(), keyword()) :: {:ok, result()} | {:error, term()}
```

Fetches Shopify products for `integration_uuid`, preferring the Admin
API and falling back to the public storefront per `decide/2`.

`result.only` travels with the result so a caller doesn't have to
re-derive it: `ProductDiff.comparable_fields/0` for the Admin source,
`[:price]` for the storefront source — hand it straight to
`ProductDiff.diff/4`'s `:only` option.

The shop domain is only looked up when the Admin API actually failed —
`decide/2` never needs it on the success path (see its first clause),
so this is meant to spare a successful sync a second
`PhoenixKit.Integrations.get_credentials/1` call it would otherwise
make and discard. That is the intent, not a pinned guarantee: nothing
in this suite currently asserts the call count.

## Options

  * `:admin_options` — forwarded to `AdminClient.fetch_products/2`.
  * `:storefront_options` — forwarded to
    `StorefrontClient.fetch_products/2`.

---

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