#19790·saleor

[spec] variant-level availability

Author: lkostrowskiCreated Sep 15, 2026Updated Sep 15, 2026

Problem

Availability flags stays on the product.

To switch availability on variant level, dashboard removes listings, which leads to prices removal (or some stock hacks)

Solution

Port availability flag to variant level. Flag is independent so listing can stay and flag false removes it from queries. This allows fine control of which variants should be visible without losing data.

API

Read

graphql
type ProductVariantChannelListing {
  id: ID!
  channel: Channel!
  price: Money
  costPrice: Money
  priorPrice: Money
  margin: Int
  isAvailableForPurchase: Boolean!            # NEW · ADDED_IN_323 → default True
}

Existing mutations

graphql
# Existing mutations - requires price - guarantees atomic write, can be still used with new flag

input ProductVariantChannelListingAddInput {
  channelId: ID!
  price: PositiveDecimal!                     # UNCHANGED, still required
  costPrice: PositiveDecimal
  priorPrice: PositiveDecimal
  isAvailableForPurchase: Boolean             # NEW · nullable
}

# Bulk - doesn't require price - discrepancy about pricing
input ChannelListingUpdateInput {
  channelListing: ID!
  price: PositiveDecimal
  costPrice: PositiveDecimal
  priorPrice: PositiveDecimal
  isAvailableForPurchase: Boolean             # NEW · nullable
}```

## New mutation

```graphql
input ProductVariantChannelListingAvailabilityInput {
  channelId: ID!
  isAvailableForPurchase: Boolean!            # required — the mutation's whole payload
}

type ProductVariantChannelListingAvailabilityUpdate {
  variant: ProductVariant
  errors: [ProductChannelListingError!]!
}

# specialized mutation that is not connected to prices, for toggling purpose 
productVariantChannelListingAvailabilityUpdate(
  id: ID
  sku: String
  input: [ProductVariantChannelListingAvailabilityInput!]!
): ProductVariantChannelListingAvailabilityUpdate

Scenarios / behavior

Migration / default

  • Given an existing variant channel listing created before the upgrade, When the migration runs, Then is_available_for_purchase is True and the variant stays purchasable.
  • Given productChannelListingUpdate(addVariants:) creates a price-less listing, When it is read, Then is_available_for_purchase is True and the variant is still not sellable (null price).

The ticket's core scenario

  • Given a variant listing with price=10.00, costPrice=4.00, priorPrice=12.00 in channel A, When availability is set to false and then back to true, Then all three amounts equal their original values and the listing pk is unchanged.

New mutation

  • Given a listing exists in channels A and B, When productVariantChannelListingAvailabilityUpdate(id:, input:[{A,false}]), Then A's flag is false, B's is true, and no price on either changed.
  • Given the variant has no listing in channel C, When the mutation targets C, Then exactly one error, code == ProductErrorCode.NOT_FOUND.name, field == "channelId", channels == [<C global id>], and no listing row is created.
  • Given a variant with a SKU, When called with sku: instead of id:, Then the same listing is updated.
  • Given two inputs naming the same channel, When called, Then exactly one error with code == DUPLICATED_INPUT_ITEM.

Existing mutations

  • Given a listing with price=10.00 and flag true, When productVariantChannelListingUpdate sends {channelId, price: 15.00} with no isAvailableForPurchase, Then price is 15.00 and the flag is still true.
  • Given the same listing with flag false, When the same call is made, Then price is 15.00 and the flag is still false (omission ≠ reset).
  • Given productVariantBulkUpdate with channelListings.update: [{channelListing, isAvailableForPurchase: false}] and no price, Then the flag is false and the price is unchanged.

Customer visibility (each with an available sibling variant so the product survives)

  • Given variant V unavailable in channel A, When an unauthenticated client queries product(channel:"A"){ variants { id } }, Then V is absent and the sibling is present.
  • productVariants(channel:"A"), Then V is absent.
  • Given V unavailable in A but available in B, When querying channel B, Then V is present.
  • Given every variant of a product is unavailable in A, When querying products(channel:"A"), Then the product is absent.
  • Given V unavailable in A, When a staff user with MANAGE_PRODUCTS queries product(channel:"A"){ variants }, Then V is present with its price intact.

Purchasability

  • Given V unavailable in A, When checkoutLinesAdd adds V, Then exactly one error, code == CheckoutErrorCode.UNAVAILABLE_VARIANT_IN_CHANNEL.name, and checkout.lines.exists() is False.
  • Given a checkout already holding V, When V is then marked unavailable and the checkout is re-fetched, Then the line reports CheckoutLineProblemVariantNotAvailable.
  • Given that checkout, When checkoutComplete runs, Then it fails with UNAVAILABLE_VARIANT_IN_CHANNEL and Order.objects.exists() is False.
  • Given V unavailable in A, When draftOrderCreate / orderLinesCreate includes V, Then exactly one error, code == OrderErrorCode.NOT_AVAILABLE_IN_CHANNEL.name, and no order line is created.
  • Given a gift promotion whose only eligible variant is V, When V is unavailable, Then no gift line is added.

Pricing

  • Given variants at 10.00 (unavailable) and 20.00 (available) in A, When product.pricing.priceRange is read after recalculation, Then start.gross.amount == 20.00.
  • Given the toggle mutation runs, Then the product's channel listing is marked dirty and the recalculated discounted_price_amount excludes the unavailable variant.
  • Given V unavailable, When a staff user reads variant.channelListings, Then price.amount == 10.00 — price survives at the read layer.