[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
type ProductVariantChannelListing {
id: ID!
channel: Channel!
price: Money
costPrice: Money
priorPrice: Money
margin: Int
isAvailableForPurchase: Boolean! # NEW · ADDED_IN_323 → default True
}Existing mutations
# 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!]!
): ProductVariantChannelListingAvailabilityUpdateScenarios / behavior
Migration / default
- Given an existing variant channel listing created before the upgrade, When the migration runs, Then
is_available_for_purchaseisTrueand the variant stays purchasable. - Given
productChannelListingUpdate(addVariants:)creates a price-less listing, When it is read, Thenis_available_for_purchaseisTrueand 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.00in channel A, When availability is set tofalseand then back totrue, Then all three amounts equal their original values and the listingpkis unchanged.
New mutation
- Given a listing exists in channels A and B, When
productVariantChannelListingAvailabilityUpdate(id:, input:[{A,false}]), Then A's flag isfalse, B's istrue, 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 ofid:, 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.00and flagtrue, WhenproductVariantChannelListingUpdatesends{channelId, price: 15.00}with noisAvailableForPurchase, Then price is15.00and the flag is stilltrue. - Given the same listing with flag
false, When the same call is made, Then price is15.00and the flag is stillfalse(omission ≠ reset). - Given
productVariantBulkUpdatewithchannelListings.update: [{channelListing, isAvailableForPurchase: false}]and no price, Then the flag isfalseand 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_PRODUCTSqueriesproduct(channel:"A"){ variants }, Then V is present with its price intact.
Purchasability
- Given V unavailable in A, When
checkoutLinesAddadds V, Then exactly one error,code == CheckoutErrorCode.UNAVAILABLE_VARIANT_IN_CHANNEL.name, andcheckout.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
checkoutCompleteruns, Then it fails withUNAVAILABLE_VARIANT_IN_CHANNELandOrder.objects.exists() is False. - Given V unavailable in A, When
draftOrderCreate/orderLinesCreateincludes 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.priceRangeis read after recalculation, Thenstart.gross.amount == 20.00. - Given the toggle mutation runs, Then the product's channel listing is marked dirty and the recalculated
discounted_price_amountexcludes the unavailable variant. - Given V unavailable, When a staff user reads
variant.channelListings, Thenprice.amount == 10.00— price survives at the read layer.
Source: saleor/saleor