Skip to content

fix(core): Assign new variants to all product channels#4699

Open
Ryrahul wants to merge 2 commits into
vendurehq:masterfrom
Ryrahul:fix/channel-product-variant-assingment
Open

fix(core): Assign new variants to all product channels#4699
Ryrahul wants to merge 2 commits into
vendurehq:masterfrom
Ryrahul:fix/channel-product-variant-assingment

Conversation

@Ryrahul

@Ryrahul Ryrahul commented May 5, 2026

Copy link
Copy Markdown
Contributor

Fixes #4532

Description

When a new product variant is created after a channel has already been assigned to the product, the new variant does not appear in that channel. This is because createSingle in ProductVariantService only assigns the variant to the current channel + default channel via assignToCurrentChannel(), without checking what other channels the parent product belongs to.

Root Cause

There is an asymmetry in channel assignment:

  • assignProductsToChannel assigns the product AND all its existing variants, option groups, and options to the target channel.
  • createSingle (variant creation) only assigns the new variant to ctx.channelId + default channel — ignoring any other channels the parent product is already assigned to.

Fix

After creating the variant and its prices for the current/default channel, we now:

  1. Load the parent product's channels (using getRepository().findOne() with relations: ['channels'] and relationLoadStrategy: 'query' — same pattern as assignProductsToChannel).
  2. Filter out channels already handled (current + default).
  3. For each additional channel:
    • Assign the variant via channelService.assignToChannels().
    • Create a ProductVariantPrice via createOrUpdateProductVariantPrice().
    • Assign the variant's option groups and options via channelService.assignToChannels() — matching what assignProductsToChannel does.

All methods used are existing public APIs already called elsewhere in the same file. No new patterns introduced.

Price handling

The new variant's price in additional channels uses input.price directly (priceFactor = 1). The original priceFactor from the initial assignProductsToChannel call is a one-time conversion factor that is not stored anywhere, so it cannot be retroactively applied to new variants. The admin can adjust channel-specific prices after creation. This is strictly better than the current behavior where the variant is completely invisible in the other channel.

Option/OptionGroup handling

When a new option is created after the product was assigned to a channel (e.g. adding a new color), ProductOptionService.create() only assigns it to the current + default channel via assignToCurrentChannel(). The fix also assigns the new variant's options and their option groups to the additional channels, preventing Cannot return null for non-nullable field ProductOption.group errors.

Scope

This fix is specific to ProductVariant creation. The same gap exists for other parent-child relationships:

Parent -> Child Same gap? Impact
Product -> ProductVariant Fixed in this PR High
Facet -> FacetValue Yes New facet values created after facet is assigned to a channel won't appear there
ProductOptionGroup -> ProductOption Yes New options won't appear in assigned channels
Collection -> Child Collection Yes Child collections created after parent is assigned won't appear there

All of these use the same assignToCurrentChannel() pattern on creation without checking the parent's channels.

Question for maintainers: Would you prefer a common helper/pattern that handles this for all parent-child relationships, or is scoping to ProductVariant sufficient for now?

Breaking changes

None. This is purely additive — new variants now get assigned to more channels than before. Existing behavior for variants created in products that are only in the default channel is unchanged (the additional channels list is empty, so no extra work is done).

Checklist

  • I have set a clear title
  • My PR is small and contains a single feature
  • I have checked my own PR
  • I have added or updated test cases
  • I have updated the README if needed

View in Codesmith
Need help on this PR? Tag @codesmith with what you need.

  • Let Codesmith autofix CI failures and bot reviews

@vercel

vercel Bot commented May 5, 2026

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated (UTC)
vendure-storybook Ready Ready Preview, Comment Jul 16, 2026 10:58am

Request Review

@coderabbitai

coderabbitai Bot commented May 5, 2026

Copy link
Copy Markdown
Contributor

Review Change Stack

Note

Reviews paused

It looks like this branch is under active development. To avoid overwhelming you with review comments due to an influx of new commits, CodeRabbit has automatically paused this review. You can configure this behavior by changing the reviews.auto_review.auto_pause_after_reviewed_commits setting.

Use the following commands to manage reviews:

  • @coderabbitai resume to resume automatic reviews.
  • @coderabbitai review to trigger a single review.

Use the checkboxes below for quick actions:

  • ▶️ Resume reviews
  • 🔍 Trigger review

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro

Run ID: dd8fd911-6b4d-441b-9fe0-67786ed786ec

📥 Commits

Reviewing files that changed from the base of the PR and between 5d1e62d and 8b7055c.

📒 Files selected for processing (1)
  • packages/core/e2e/product-channel.e2e-spec.ts
🚧 Files skipped from review as they are similar to previous changes (1)
  • packages/core/e2e/product-channel.e2e-spec.ts

📝 Walkthrough

Walkthrough

Product variant creation now assigns new variants to additional channels already linked to the parent product, including related option groups and options. The E2E suite verifies channel visibility, pricing, stock, assets, option relationships, and permission-denied rollback behavior.

Possibly related PRs

Suggested reviewers: michaelbromley

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Title check ✅ Passed The title clearly describes the core change: assigning newly created variants to all channels where the product already exists.
Description check ✅ Passed The description covers the issue, root cause, fix, breaking changes, and checklist, though it omits a Screenshots section.
Linked Issues check ✅ Passed The code and tests address #4532 by assigning new variants to the product's additional channels so they appear in already assigned channels.
Out of Scope Changes check ✅ Passed The added tests and service changes stay focused on variant channel assignment and related pricing, stock, and option linkage.
Docstring Coverage ✅ Passed No functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check.
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🧹 Nitpick comments (1)
packages/core/src/service/services/product-variant.service.ts (1)

506-542: ⚡ Quick win

Batch the per-channel assignments instead of looping per channel.

channelService.assignToChannels(ctx, EntityType, entityId, channelIds) accepts an array of channel IDs and batches them in a single relation operation, so the variant / option-group / option assignments can each be issued once with additionalChannelIds instead of looping. The method deduplicates automatically, making this safe. Only createOrUpdateProductVariantPrice needs to stay per-channel because each channel may have its own defaultCurrencyCode. This reduces DB round-trips on variant creation when a product spans many channels and/or has many options.

♻️ Proposed batched form
-                for (const additionalChannelId of additionalChannelIds) {
-                    const channel = await this.connection.getEntityOrThrow(
-                        ctx,
-                        Channel,
-                        additionalChannelId,
-                    );
-                    await this.channelService.assignToChannels(
-                        ctx,
-                        ProductVariant,
-                        createdVariant.id,
-                        [additionalChannelId],
-                    );
-                    await this.createOrUpdateProductVariantPrice(
-                        ctx,
-                        createdVariant.id,
-                        input.price,
-                        additionalChannelId,
-                        channel.defaultCurrencyCode,
-                    );
-                    // Also assign option groups and options to the target channel
-                    for (const groupId of optionGroupIds) {
-                        await this.channelService.assignToChannels(
-                            ctx,
-                            ProductOptionGroup,
-                            groupId,
-                            [additionalChannelId],
-                        );
-                    }
-                    for (const optionId of optionIds) {
-                        await this.channelService.assignToChannels(
-                            ctx,
-                            ProductOption,
-                            optionId,
-                            [additionalChannelId],
-                        );
-                    }
-                }
+                // Assign the variant to all additional channels in one call
+                await this.channelService.assignToChannels(
+                    ctx,
+                    ProductVariant,
+                    createdVariant.id,
+                    additionalChannelIds,
+                );
+                // Assign option groups and options to all additional channels in one call each
+                for (const groupId of optionGroupIds) {
+                    await this.channelService.assignToChannels(
+                        ctx,
+                        ProductOptionGroup,
+                        groupId,
+                        additionalChannelIds,
+                    );
+                }
+                for (const optionId of optionIds) {
+                    await this.channelService.assignToChannels(
+                        ctx,
+                        ProductOption,
+                        optionId,
+                        additionalChannelIds,
+                    );
+                }
+                // Per-channel price creation (each channel can have its own defaultCurrencyCode)
+                const additionalChannels = await this.connection
+                    .getRepository(ctx, Channel)
+                    .find({ where: { id: In(additionalChannelIds) } });
+                for (const channel of additionalChannels) {
+                    await this.createOrUpdateProductVariantPrice(
+                        ctx,
+                        createdVariant.id,
+                        input.price,
+                        channel.id,
+                        channel.defaultCurrencyCode,
+                    );
+                }
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@packages/core/src/service/services/product-variant.service.ts` around lines
506 - 542, Replace the per-channel loops with batched assignments: call
this.channelService.assignToChannels once for the createdVariant
(ProductVariant) with the full additionalChannelIds array, once for all option
groups (ProductOptionGroup) using optionGroupIds, and once for all options
(ProductOption) using optionIds; keep the per-channel loop only for
createOrUpdateProductVariantPrice since it needs each channel's
defaultCurrencyCode (use this.connection.getEntityOrThrow to fetch each channel
inside that loop to read channel.defaultCurrencyCode). This removes the repeated
assignToChannels calls while preserving per-channel price creation.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Nitpick comments:
In `@packages/core/src/service/services/product-variant.service.ts`:
- Around line 506-542: Replace the per-channel loops with batched assignments:
call this.channelService.assignToChannels once for the createdVariant
(ProductVariant) with the full additionalChannelIds array, once for all option
groups (ProductOptionGroup) using optionGroupIds, and once for all options
(ProductOption) using optionIds; keep the per-channel loop only for
createOrUpdateProductVariantPrice since it needs each channel's
defaultCurrencyCode (use this.connection.getEntityOrThrow to fetch each channel
inside that loop to read channel.defaultCurrencyCode). This removes the repeated
assignToChannels calls while preserving per-channel price creation.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro

Run ID: 608c97d3-9894-4aed-a313-89d4bb95593e

📥 Commits

Reviewing files that changed from the base of the PR and between f68c183 and 329ad04.

📒 Files selected for processing (2)
  • packages/core/e2e/product-channel.e2e-spec.ts
  • packages/core/src/service/services/product-variant.service.ts

@grolmus grolmus left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks for tackling this, @Ryrahul — the bug in #4532 is real and your e2e nicely captures the symptom. Before this can go into core I'd like to see it reworked to build on the existing machinery rather than adding a parallel path.

Reuse assignProductVariantsToChannel. ProductVariantService.assignProductVariantsToChannel already handles everything a new variant needs when it enters another channel: the permission check (UpdateCatalog on the target channel), correct price derivation from the channel's pricesIncludeTax plus priceFactor, defaultCurrencyCode, stock-level seeding, asset assignment, and the ProductVariantChannelEvent. ProductService.assignProductsToChannel is the model for also propagating option groups/options. The current inline implementation in createSingle reproduces a subset of this and misses several pieces:

  • Pricing: it writes the raw input.price as the net price into every channel, ignoring tax-inclusive channels, per-channel currency, and priceFactor. Please derive the price the same way assignProductVariantsToChannel does.
  • Permissions: there's no check that the user may write to the other channels — please don't bypass userHasPermissionOnChannel. As written, a user with catalog rights only on the current channel could cause writes into channels they can't administer.
  • Events: no ProductVariantChannelEvent is published, so search indexing / plugins won't learn the variant now exists in the other channels — meaning the variant may not actually become searchable there (i.e. the fix may not fully deliver on its goal).
  • Stock & assets: no stock-level seeding (see #4864) and no asset assignment for the new channels, so stockLevels resolves to [] and a featured asset is invisible in the newly-covered channel.

Concretely, after the variant is created you should be able to compute the product's "other" channel IDs and route them through the existing assign method (or a shared helper extracted from it) rather than hand-rolling the loop.

A couple of practical notes:

  • The branch currently conflicts with master and needs a rebase — createSingle has changed around the price-seeding block.
  • Once pricing goes through the canonical path, the priceWithTax === 2000 assertion will need recomputing, and please double-check the query fragment actually selects currencyCode.
  • Could you extend the e2e to cover stock levels + assets in the assigned channel, a priceFactor / tax-inclusive pricing case, and a negative-permission case?

Happy to help map out the refactor if useful — thanks again for the contribution.

@Ryrahul

Ryrahul commented Jul 8, 2026

Copy link
Copy Markdown
Contributor Author

Thanks @grolmus for the review !. Will address the mentioned changes 👐

@grolmus grolmus left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Excellent rework, @Ryrahul — this now builds on assignProductVariantsToChannel exactly as hoped, so pricing (incl. tax-inclusive channels), permissions, stock seeding, assets and ProductVariantChannelEvent all come for free and stay consistent with ProductService.assignProductsToChannel. The option-group/option propagation is the right call, and the @Transaction() wrapper means a ForbiddenError on a target channel cleanly rolls back the create — nicely covered by the new negative-permission test. The extended e2e (tax-inclusive price, stock, assets, options, permissions) is exactly what I was hoping for.

A few notes — one worth tightening before I approve, the rest optional:

  • Stock test: it asserts stockOnHand === 0, which is what you'd get whether or not a StockLevel row was actually seeded for the channel. Could you assert on the channel-filtered stockLevels array (its length / entry) instead? That's what actually proves ensureStockLevelsForChannel did its job — the behaviour this test is meant to guard.
  • Optional: the comment around the price test says "priceWithTax (2000)" — 2000 is the net price; the gross is 2400 (which the assertion correctly checks). Just the wording.
  • Optional: after the permission failure, assert that no orphaned CHAN-VAR-GREEN variant exists. The @Transaction() already guarantees the rollback, but it makes the intent explicit.

Once the stock assertion is tightened I'm happy to approve — the implementation itself looks great.

Address review feedback on the new-variant channel-assignment tests:

- Stock test now asserts on the channel-filtered `stockLevels` array (queried
  from the target channel) instead of `stockOnHand === 0`. This proves
  ensureStockLevelsForChannel actually seeded a StockLevel row — a check that
  `stockOnHand === 0` alone could not distinguish from "no row seeded". The
  test now assigns the default stock location to the target channel first, so
  seeding has a location to write to.
- Add an explicit assertion that no orphaned variant remains after a
  permission-denied create, making the @transaction() rollback intent explicit.
- Clarify the tax-inclusive price comment (2000 is net; 2400 is gross).

Relates to vendurehq#4532
@Ryrahul

Ryrahul commented Jul 16, 2026

Copy link
Copy Markdown
Contributor Author

Excellent rework, @Ryrahul — this now builds on assignProductVariantsToChannel exactly as hoped, so pricing (incl. tax-inclusive channels), permissions, stock seeding, assets and ProductVariantChannelEvent all come for free and stay consistent with ProductService.assignProductsToChannel. The option-group/option propagation is the right call, and the @Transaction() wrapper means a ForbiddenError on a target channel cleanly rolls back the create — nicely covered by the new negative-permission test. The extended e2e (tax-inclusive price, stock, assets, options, permissions) is exactly what I was hoping for.

A few notes — one worth tightening before I approve, the rest optional:

  • Stock test: it asserts stockOnHand === 0, which is what you'd get whether or not a StockLevel row was actually seeded for the channel. Could you assert on the channel-filtered stockLevels array (its length / entry) instead? That's what actually proves ensureStockLevelsForChannel did its job — the behaviour this test is meant to guard.
  • Optional: the comment around the price test says "priceWithTax (2000)" — 2000 is the net price; the gross is 2400 (which the assertion correctly checks). Just the wording.
  • Optional: after the permission failure, assert that no orphaned CHAN-VAR-GREEN variant exists. The @Transaction() already guarantees the rollback, but it makes the intent explicit.

Once the stock assertion is tightened I'm happy to approve — the implementation itself looks great.

Hi @grolmus, I've made the requested changes. Could you please review it again when you have a moment? Thanks for your time!

@Ryrahul Ryrahul requested a review from grolmus July 16, 2026 12:39
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Additional product variant not visible in assigned channel when created after channel assignment

2 participants