Explorar o código

feat(core): Resolve & update product variant prices

Michael Bromley %!s(int64=2) %!d(string=hai) anos
pai
achega
c15dd23233

+ 4 - 0
packages/core/e2e/graphql/fragments.ts

@@ -43,6 +43,10 @@ export const PRODUCT_VARIANT_FRAGMENT = gql`
         currencyCode
         price
         priceWithTax
+        prices {
+            currencyCode
+            price
+        }
         stockOnHand
         trackInventory
         taxRateApplied {

A diferenza do arquivo foi suprimida porque é demasiado grande
+ 31 - 12
packages/core/e2e/graphql/generated-e2e-admin-types.ts


+ 180 - 0
packages/core/e2e/product-prices.e2e-spec.ts

@@ -0,0 +1,180 @@
+/* eslint-disable @typescript-eslint/no-non-null-assertion */
+import { createTestEnvironment } from '@vendure/testing';
+import path from 'path';
+import { afterAll, beforeAll, describe, expect, it } from 'vitest';
+
+import { initialData } from '../../../e2e-common/e2e-initial-data';
+import { TEST_SETUP_TIMEOUT_MS, testConfig } from '../../../e2e-common/test-config';
+
+import * as Codegen from './graphql/generated-e2e-admin-types';
+import {
+    CreateProductDocument,
+    CreateProductVariantsDocument,
+    CurrencyCode,
+    GetProductWithVariantsDocument,
+    LanguageCode,
+    UpdateChannelDocument,
+    UpdateProductVariantsDocument,
+} from './graphql/generated-e2e-admin-types';
+import { assertThrowsWithMessage } from './utils/assert-throws-with-message';
+
+describe('Product prices', () => {
+    const { server, adminClient, shopClient } = createTestEnvironment({ ...testConfig() });
+
+    let multiPriceProduct: Codegen.CreateProductMutation['createProduct'];
+    let multiPriceVariant: NonNullable<
+        Codegen.CreateProductVariantsMutation['createProductVariants'][number]
+    >;
+
+    beforeAll(async () => {
+        await server.init({
+            initialData,
+            customerCount: 1,
+            productsCsvPath: path.join(__dirname, 'fixtures/e2e-products-full.csv'),
+        });
+        await adminClient.asSuperAdmin();
+        await adminClient.query(UpdateChannelDocument, {
+            input: {
+                id: 'T_1',
+                availableCurrencyCodes: [CurrencyCode.USD, CurrencyCode.GBP, CurrencyCode.EUR],
+            },
+        });
+        const { createProduct } = await adminClient.query(CreateProductDocument, {
+            input: {
+                translations: [
+                    {
+                        languageCode: LanguageCode.en,
+                        name: 'Cactus',
+                        slug: 'cactus',
+                        description: 'A prickly plant',
+                    },
+                ],
+            },
+        });
+        multiPriceProduct = createProduct;
+    }, TEST_SETUP_TIMEOUT_MS);
+
+    afterAll(async () => {
+        await server.destroy();
+    });
+
+    it('create ProductVariant creates price in Channel default currency', async () => {
+        const { createProductVariants } = await adminClient.query(CreateProductVariantsDocument, {
+            input: [
+                {
+                    productId: multiPriceProduct.id,
+                    sku: 'CACTUS-1',
+                    optionIds: [],
+                    translations: [{ languageCode: LanguageCode.de, name: 'Cactus' }],
+                    price: 1000,
+                },
+            ],
+        });
+
+        expect(createProductVariants.length).toBe(1);
+        expect(createProductVariants[0]?.prices).toEqual([
+            {
+                currencyCode: CurrencyCode.USD,
+                price: 1000,
+            },
+        ]);
+        multiPriceVariant = createProductVariants[0]!;
+    });
+
+    it(
+        'updating ProductVariant with price in unavailable currency throws',
+        assertThrowsWithMessage(async () => {
+            await adminClient.query(UpdateProductVariantsDocument, {
+                input: {
+                    id: multiPriceVariant.id,
+                    prices: [
+                        {
+                            currencyCode: CurrencyCode.JPY,
+                            price: 100000,
+                        },
+                    ],
+                },
+            });
+        }, 'The currency "JPY" is not available in the current Channel'),
+    );
+
+    it('updates ProductVariant with multiple prices', async () => {
+        await adminClient.query(UpdateProductVariantsDocument, {
+            input: {
+                id: multiPriceVariant.id,
+                prices: [
+                    { currencyCode: CurrencyCode.USD, price: 1200 },
+                    { currencyCode: CurrencyCode.GBP, price: 900 },
+                    { currencyCode: CurrencyCode.EUR, price: 1100 },
+                ],
+            },
+        });
+
+        const { product } = await adminClient.query(GetProductWithVariantsDocument, {
+            id: multiPriceProduct.id,
+        });
+
+        expect(product?.variants[0]?.prices.sort((a, b) => a.price - b.price)).toEqual([
+            { currencyCode: CurrencyCode.GBP, price: 900 },
+            { currencyCode: CurrencyCode.EUR, price: 1100 },
+            { currencyCode: CurrencyCode.USD, price: 1200 },
+        ]);
+    });
+
+    it('deletes a price in a non-default currency', async () => {
+        await adminClient.query(UpdateProductVariantsDocument, {
+            input: {
+                id: multiPriceVariant.id,
+                prices: [{ currencyCode: CurrencyCode.EUR, price: 1100, delete: true }],
+            },
+        });
+
+        const { product } = await adminClient.query(GetProductWithVariantsDocument, {
+            id: multiPriceProduct.id,
+        });
+
+        expect(product?.variants[0]?.prices.sort((a, b) => a.price - b.price)).toEqual([
+            { currencyCode: CurrencyCode.GBP, price: 900 },
+            { currencyCode: CurrencyCode.USD, price: 1200 },
+        ]);
+    });
+
+    describe('DefaultProductVariantPriceSelectionStrategy', () => {
+        it('defaults to default Channel currency', async () => {
+            const { product } = await adminClient.query(GetProductWithVariantsDocument, {
+                id: multiPriceProduct.id,
+            });
+
+            expect(product?.variants[0]?.price).toEqual(1200);
+            expect(product?.variants[0]?.priceWithTax).toEqual(1200 * 1.2);
+            expect(product?.variants[0]?.currencyCode).toEqual(CurrencyCode.USD);
+        });
+
+        it('uses query string to select currency', async () => {
+            const { product } = await adminClient.query(
+                GetProductWithVariantsDocument,
+                {
+                    id: multiPriceProduct.id,
+                },
+                { currencyCode: 'GBP' },
+            );
+
+            expect(product?.variants[0]?.price).toEqual(900);
+            expect(product?.variants[0]?.priceWithTax).toEqual(900 * 1.2);
+            expect(product?.variants[0]?.currencyCode).toEqual(CurrencyCode.GBP);
+        });
+
+        it('uses default if unrecognised currency code passed in query string', async () => {
+            const { product } = await adminClient.query(
+                GetProductWithVariantsDocument,
+                {
+                    id: multiPriceProduct.id,
+                },
+                { currencyCode: 'JPY' },
+            );
+
+            expect(product?.variants[0]?.price).toEqual(1200);
+            expect(product?.variants[0]?.currencyCode).toEqual(CurrencyCode.USD);
+        });
+    });
+});

+ 10 - 12
packages/core/e2e/product.e2e-spec.ts

@@ -7,11 +7,17 @@ import path from 'path';
 import { afterAll, beforeAll, describe, expect, it } from 'vitest';
 
 import { initialData } from '../../../e2e-common/e2e-initial-data';
-import { testConfig, TEST_SETUP_TIMEOUT_MS } from '../../../e2e-common/test-config';
+import { TEST_SETUP_TIMEOUT_MS, testConfig } from '../../../e2e-common/test-config';
 
 import { PRODUCT_VARIANT_FRAGMENT, PRODUCT_WITH_OPTIONS_FRAGMENT } from './graphql/fragments';
 import * as Codegen from './graphql/generated-e2e-admin-types';
-import { DeletionResult, ErrorCode, LanguageCode, SortOrder } from './graphql/generated-e2e-admin-types';
+import {
+    DeletionResult,
+    ErrorCode,
+    LanguageCode,
+    SortOrder,
+    UpdateChannelDocument,
+} from './graphql/generated-e2e-admin-types';
 import {
     ADD_OPTION_GROUP_TO_PRODUCT,
     CREATE_PRODUCT,
@@ -24,7 +30,6 @@ import {
     GET_PRODUCT_SIMPLE,
     GET_PRODUCT_VARIANT_LIST,
     GET_PRODUCT_WITH_VARIANTS,
-    UPDATE_CHANNEL,
     UPDATE_GLOBAL_SETTINGS,
     UPDATE_PRODUCT,
     UPDATE_PRODUCT_VARIANTS,
@@ -36,7 +41,6 @@ import { assertThrowsWithMessage } from './utils/assert-throws-with-message';
 describe('Product resolver', () => {
     const { server, adminClient, shopClient } = createTestEnvironment({
         ...testConfig(),
-        // logger: new DefaultLogger(),
     });
 
     const removeOptionGuard: ErrorResultGuard<Codegen.ProductWithOptionsFragment> = createErrorResultGuard(
@@ -1966,10 +1970,7 @@ describe('Product resolver', () => {
 
                 afterAll(async () => {
                     // Restore the default language to English for the subsequent tests
-                    await adminClient.query<
-                        Codegen.UpdateChannelMutation,
-                        Codegen.UpdateChannelMutationVariables
-                    >(UPDATE_CHANNEL, {
+                    await adminClient.query(UpdateChannelDocument, {
                         input: {
                             id: 'T_1',
                             defaultLanguageCode: LanguageCode.en,
@@ -1991,10 +1992,7 @@ describe('Product resolver', () => {
                     expect(product1?.variants.length).toBe(1);
 
                     // Change the default language of the channel to "de"
-                    const { updateChannel } = await adminClient.query<
-                        Codegen.UpdateChannelMutation,
-                        Codegen.UpdateChannelMutationVariables
-                    >(UPDATE_CHANNEL, {
+                    const { updateChannel } = await adminClient.query(UpdateChannelDocument, {
                         input: {
                             id: 'T_1',
                             defaultLanguageCode: LanguageCode.de,

+ 18 - 1
packages/core/src/api/resolvers/entity/product-variant-entity.resolver.ts

@@ -1,5 +1,9 @@
 import { Args, Parent, ResolveField, Resolver } from '@nestjs/graphql';
-import { CurrencyCode, StockMovementListOptions } from '@vendure/common/lib/generated-types';
+import {
+    CurrencyCode,
+    ProductVariantPrice,
+    StockMovementListOptions,
+} from '@vendure/common/lib/generated-types';
 import { DEFAULT_CHANNEL_CODE } from '@vendure/common/lib/shared-constants';
 import { PaginatedList } from '@vendure/common/lib/shared-types';
 
@@ -203,4 +207,17 @@ export class ProductVariantAdminEntityResolver {
     ): Promise<StockLevel[]> {
         return this.stockLevelService.getStockLevelsForVariant(ctx, productVariant.id);
     }
+
+    @ResolveField()
+    async prices(
+        @Ctx() ctx: RequestContext,
+        @Parent() productVariant: ProductVariant,
+    ): Promise<ProductVariantPrice[]> {
+        if (productVariant.productVariantPrices) {
+            return productVariant.productVariantPrices.filter(pvp =>
+                idsAreEqual(pvp.channelId, ctx.channelId),
+            );
+        }
+        return this.productVariantService.getProductVariantPrices(ctx, productVariant.id);
+    }
 }

+ 6 - 0
packages/core/src/api/schema/admin-api/product-admin.type.graphql

@@ -3,6 +3,11 @@ type Product implements Node {
     channels: [Channel!]!
 }
 
+type ProductVariantPrice {
+    currencyCode: CurrencyCode!
+    price: Int!
+}
+
 type ProductVariant implements Node {
     enabled: Boolean!
     trackInventory: GlobalFlag!
@@ -10,6 +15,7 @@ type ProductVariant implements Node {
     stockAllocated: Int! @deprecated(reason: "use stockLevels")
     outOfStockThreshold: Int!
     useGlobalOutOfStockThreshold: Boolean!
+    prices: [ProductVariantPrice!]!
     stockLevels: [StockLevel!]!
     stockMovements(options: StockMovementListOptions): StockMovementList!
     channels: [Channel!]!

+ 17 - 0
packages/core/src/api/schema/admin-api/product.api.graphql

@@ -129,6 +129,16 @@ input StockLevelInput {
     stockOnHand: Int!
 }
 
+"""
+Used to set up update the price of a ProductVariant in a particular Channel.
+If the `delete` flag is `true`, the price will be deleted for the given Channel.
+"""
+input ProductVariantPriceInput {
+    currencyCode: CurrencyCode!
+    price: Money!
+    delete: Boolean
+}
+
 input CreateProductVariantInput {
     productId: ID!
     translations: [ProductVariantTranslationInput!]!
@@ -154,7 +164,14 @@ input UpdateProductVariantInput {
     optionIds: [ID!]
     sku: String
     taxCategoryId: ID
+    """
+    Sets the price for the ProductVariant in the Channel's default currency
+    """
     price: Money
+    """
+    Allows multiple prices to be set for the ProductVariant in different currencies.
+    """
+    prices: [ProductVariantPriceInput!]
     featuredAssetId: ID
     assetIds: [ID!]
     stockOnHand: Int

+ 10 - 2
packages/core/src/config/catalog/default-product-variant-price-selection-strategy.ts

@@ -6,10 +6,18 @@ import { ProductVariantPriceSelectionStrategy } from './product-variant-price-se
 
 /**
  * @description
- * The default strategy for selecting the price for a ProductVariant in a given Channel.
+ * The default strategy for selecting the price for a ProductVariant in a given Channel. It
+ * first filters all available prices to those which are in the current Channel, and then
+ * selects the first price which matches the current currency.
+ *
+ * @docsCategory configuration
+ * @docsPage ProductVariantPriceSelectionStrategy
+ * @since 2.0.0
  */
 export class DefaultProductVariantPriceSelectionStrategy implements ProductVariantPriceSelectionStrategy {
     selectPrice(ctx: RequestContext, prices: ProductVariantPrice[]) {
-        return prices.find(p => idsAreEqual(p.channelId, ctx.channelId));
+        const pricesInChannel = prices.filter(p => idsAreEqual(p.channelId, ctx.channelId));
+        const priceInCurrency = pricesInChannel.find(p => p.currencyCode === ctx.currencyCode);
+        return priceInCurrency || pricesInChannel[0];
     }
 }

+ 9 - 0
packages/core/src/config/catalog/product-variant-price-selection-strategy.ts

@@ -2,6 +2,15 @@ import { RequestContext } from '../../api/common/request-context';
 import { InjectableStrategy } from '../../common/types/injectable-strategy';
 import { ProductVariantPrice } from '../../entity/product-variant/product-variant-price.entity';
 
+/**
+ * @description
+ * The strategy for selecting the price for a ProductVariant in a given Channel.
+ *
+ * @docsCategory configuration
+ * @docsPage ProductVariantPriceSelectionStrategy
+ * @docsWeight 0
+ * @since 2.0.0
+ */
 export interface ProductVariantPriceSelectionStrategy extends InjectableStrategy {
     selectPrice(
         ctx: RequestContext,

+ 1 - 0
packages/core/src/i18n/messages/en.json

@@ -16,6 +16,7 @@
     "conditions-required-for-action": "The PromotionAction '{ action }' requires the following conditions: { conditions }",
     "configurable-argument-is-required": "The argument '{ name }' is required",
     "country-code-not-valid": "The countryCode \"{ countryCode }\" was not recognized",
+    "currency-not-available-in-channel": "The currency \"{ currencyCode }\" is not available in the current Channel",
     "customer-does-not-belong-to-customer-group": "Customer does not belong to this CustomerGroup",
     "default-channel-not-found": "Default channel not found",
     "entity-has-no-translation-in-language": "Translatable entity '{ entityName }' has not been translated into the requested language ({ languageCode })",

+ 48 - 1
packages/core/src/service/services/product-variant.service.ts

@@ -2,6 +2,7 @@ import { Injectable } from '@nestjs/common';
 import {
     AssignProductVariantsToChannelInput,
     CreateProductVariantInput,
+    CurrencyCode,
     DeletionResponse,
     DeletionResult,
     GlobalFlag,
@@ -240,6 +241,15 @@ export class ProductVariantService {
         return variant.channels;
     }
 
+    async getProductVariantPrices(ctx: RequestContext, productVariantId: ID): Promise<ProductVariantPrice[]> {
+        return this.connection
+            .getRepository(ctx, ProductVariantPrice)
+            .createQueryBuilder('pvp')
+            .where('pvp.productVariant = :productVariantId', { productVariantId })
+            .andWhere('pvp.channelId = :channelId', { channelId: ctx.channelId })
+            .getMany();
+    }
+
     /**
      * @description
      * Returns the ProductVariant associated with the given {@link OrderLine}.
@@ -513,30 +523,67 @@ export class ProductVariantService {
         if (input.price != null) {
             await this.createOrUpdateProductVariantPrice(ctx, input.id, input.price, ctx.channelId);
         }
+        if (input.prices) {
+            for (const priceInput of input.prices) {
+                if (priceInput.delete === true) {
+                    const variantPrice = await this.connection
+                        .getRepository(ctx, ProductVariantPrice)
+                        .findOne({
+                            where: {
+                                variant: { id: input.id },
+                                channelId: ctx.channelId,
+                                currencyCode: priceInput.currencyCode,
+                            },
+                        });
+                    if (variantPrice) {
+                        await this.connection.getRepository(ctx, ProductVariantPrice).remove(variantPrice);
+                    }
+                } else {
+                    await this.createOrUpdateProductVariantPrice(
+                        ctx,
+                        input.id,
+                        priceInput.price,
+                        ctx.channelId,
+                        priceInput.currencyCode,
+                    );
+                }
+            }
+        }
         return updatedVariant.id;
     }
 
     /**
      * @description
      * Creates a {@link ProductVariantPrice} for the given ProductVariant/Channel combination.
+     * If the `currencyCode` is not specified, the default currency of the Channel will be used.
      */
     async createOrUpdateProductVariantPrice(
         ctx: RequestContext,
         productVariantId: ID,
         price: number,
         channelId: ID,
+        currencyCode?: CurrencyCode,
     ): Promise<ProductVariantPrice> {
         let variantPrice = await this.connection.getRepository(ctx, ProductVariantPrice).findOne({
             where: {
                 variant: { id: productVariantId },
                 channelId,
+                currencyCode: currencyCode ?? ctx.channel.defaultCurrencyCode,
             },
         });
+        if (currencyCode) {
+            const channel = await this.channelService.findOne(ctx, channelId);
+            if (!channel?.availableCurrencyCodes.includes(currencyCode)) {
+                throw new UserInputError('error.currency-not-available-in-channel', {
+                    currencyCode,
+                });
+            }
+        }
         if (!variantPrice) {
             variantPrice = new ProductVariantPrice({
                 channelId,
                 variant: new ProductVariant({ id: productVariantId }),
-                currencyCode: ctx.currencyCode,
+                currencyCode: currencyCode ?? ctx.channel.defaultCurrencyCode,
             });
         }
         variantPrice.price = price;

+ 19 - 0
packages/elasticsearch-plugin/e2e/graphql/generated-e2e-elasticsearch-plugin-types.ts

@@ -4494,6 +4494,7 @@ export type ProductVariant = Node & {
   outOfStockThreshold: Scalars['Int'];
   price: Scalars['Money'];
   priceWithTax: Scalars['Money'];
+  prices: Array<ProductVariantPrice>;
   product: Product;
   productId: Scalars['ID'];
   sku: Scalars['String'];
@@ -4556,6 +4557,21 @@ export type ProductVariantListOptions = {
   take?: InputMaybe<Scalars['Int']>;
 };
 
+export type ProductVariantPrice = {
+  currencyCode: CurrencyCode;
+  price: Scalars['Int'];
+};
+
+/**
+ * Used to set up update the price of a ProductVariant in a particular Channel.
+ * If the `delete` flag is `true`, the price will be deleted for the given Channel.
+ */
+export type ProductVariantPriceInput = {
+  currencyCode: CurrencyCode;
+  delete?: InputMaybe<Scalars['Boolean']>;
+  price: Scalars['Money'];
+};
+
 export type ProductVariantSortParameter = {
   createdAt?: InputMaybe<SortOrder>;
   id?: InputMaybe<SortOrder>;
@@ -6103,7 +6119,10 @@ export type UpdateProductVariantInput = {
   id: Scalars['ID'];
   optionIds?: InputMaybe<Array<Scalars['ID']>>;
   outOfStockThreshold?: InputMaybe<Scalars['Int']>;
+  /** Sets the price for the ProductVariant in the Channel's default currency */
   price?: InputMaybe<Scalars['Money']>;
+  /** Allows multiple prices to be set for the ProductVariant in different currencies. */
+  prices?: InputMaybe<Array<ProductVariantPriceInput>>;
   sku?: InputMaybe<Scalars['String']>;
   stockLevels?: InputMaybe<Array<StockLevelInput>>;
   stockOnHand?: InputMaybe<Scalars['Int']>;

+ 19 - 0
packages/payments-plugin/e2e/graphql/generated-admin-types.ts

@@ -4494,6 +4494,7 @@ export type ProductVariant = Node & {
   outOfStockThreshold: Scalars['Int'];
   price: Scalars['Money'];
   priceWithTax: Scalars['Money'];
+  prices: Array<ProductVariantPrice>;
   product: Product;
   productId: Scalars['ID'];
   sku: Scalars['String'];
@@ -4556,6 +4557,21 @@ export type ProductVariantListOptions = {
   take?: InputMaybe<Scalars['Int']>;
 };
 
+export type ProductVariantPrice = {
+  currencyCode: CurrencyCode;
+  price: Scalars['Int'];
+};
+
+/**
+ * Used to set up update the price of a ProductVariant in a particular Channel.
+ * If the `delete` flag is `true`, the price will be deleted for the given Channel.
+ */
+export type ProductVariantPriceInput = {
+  currencyCode: CurrencyCode;
+  delete?: InputMaybe<Scalars['Boolean']>;
+  price: Scalars['Money'];
+};
+
 export type ProductVariantSortParameter = {
   createdAt?: InputMaybe<SortOrder>;
   id?: InputMaybe<SortOrder>;
@@ -6103,7 +6119,10 @@ export type UpdateProductVariantInput = {
   id: Scalars['ID'];
   optionIds?: InputMaybe<Array<Scalars['ID']>>;
   outOfStockThreshold?: InputMaybe<Scalars['Int']>;
+  /** Sets the price for the ProductVariant in the Channel's default currency */
   price?: InputMaybe<Scalars['Money']>;
+  /** Allows multiple prices to be set for the ProductVariant in different currencies. */
+  prices?: InputMaybe<Array<ProductVariantPriceInput>>;
   sku?: InputMaybe<Scalars['String']>;
   stockLevels?: InputMaybe<Array<StockLevelInput>>;
   stockOnHand?: InputMaybe<Scalars['Int']>;

A diferenza do arquivo foi suprimida porque é demasiado grande
+ 0 - 0
schema-admin.json


Algúns arquivos non se mostraron porque demasiados arquivos cambiaron neste cambio