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

Pure mapping from a Shopify Admin API product payload's `"options"`/
`"variants"` into the shape `PhoenixKitEcommerce.Catalogue.Writer.
sync_variants/2` attaches as catalogue attribute sets: one set per real
option (Shopify's auto-generated single-option "no variants" product —
option name `"Title"`, its lone value `"Default Title"` — is skipped,
same as it never becomes a `_option_slots` entry on the legacy CSV
path), values in the order they first appear across `variants[]`, and
a per-value price modifier.

No I/O, no catalogue/entities lookups — `build/1` never needs a live
set to exist. `slug` (both the set's and each modifier map's key) is
`PhoenixKitEcommerce.Catalogue.SetSlug.normalise/1` of the option's
OWN name; resolving a raw variant-option VALUE label to a value slug
is a separate, stateful step (`PhoenixKitEcommerce.Catalogue.
ValueResolver`) `Writer.sync_variants/2` runs afterward, against
whatever catalogue set that slug now names.

## Modifier rule

For each option, group `variants[]` by that option's value
(`variant["option<position>"]`); a value's modifier is
`min(price of variants carrying that value) − min(price of every
priced variant on the product)`. Grouping by MIN rather than "first
variant seen" (the legacy `Import.OptionBuilder`'s rule, correct only
when a single option drives price) matters here because two options
combine on one variant — the cheapest variant carrying value X is the
fair anchor for X's modifier regardless of what its OTHER option
happened to be. The overall cheapest value's modifier is exactly
`Decimal.new("0.00")` (equal minima, not a rounded near-zero) as long
as Shopify's own price strings carry two decimals, which
`Decimal.sub/2` preserves.

## Non-additive matrices are reported, not hidden

Per-option modifiers can only ever express an ADDITIVE price matrix:
the storefront prices a selection as `base + Σ modifier(value)`. A
Shopify matrix that is not additive — S/L × Red/Blue at 10/12/15/20
gives S:0, L:5, Red:0, Blue:2 and predicts 17 for L-Blue, where
Shopify charges 20 — is silently under-priced by that reconstruction.
`build/1` therefore re-derives every priced variant's price from the
modifiers it just computed and lists each mismatch in `:warnings`
(one human-readable line per variant, naming the product), logging
each at `:warning` as well. The modifiers are still returned — they
are the best additive fit — but a caller writing them must surface the
warnings (the media sync worker records them on the run's per-product
errors) rather than let the write pass as clean.

# `set`

```elixir
@type set() :: %{
  name: String.t(),
  slug: String.t(),
  values: [String.t()],
  position: pos_integer()
}
```

# `t`

```elixir
@type t() :: %{
  sets: [set()],
  modifiers: %{required(String.t()) =&gt; %{required(String.t()) =&gt; Decimal.t()}},
  warnings: [String.t()]
}
```

# `build`

```elixir
@spec build(map()) :: t()
```

Builds `%{sets: [...], modifiers: %{set_slug => %{label => Decimal}},
warnings: [...]}` from `shopify_product`'s `"options"` and
`"variants"`. Both keys default to `[]` when absent (a payload with no
options at all — every variant on the default "Title" option — yields
`sets: [], modifiers: %{}, warnings: []`). See the moduledoc for what
`:warnings` carries.

---

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