Просмотр исходного кода

feat(core): Add currencyCode to variant price model

Relates to #1691
This change puts in place a data model which will allow us to support multiple currencies per
channel, and will remove the requirement that a new price be set for
each channel that a variant is assigned to.

BREAKING CHANGE: The `Channel.currencyCode` field has been renamed to `defaultCurrencyCode`, and a
new `currencyCode` field has been added to the `ProductVariantPrice` entity. This will require
a database migration with care taken to preserve exiting data.
Michael Bromley 2 лет назад
Родитель
Сommit
24e558b04e

+ 1 - 1
packages/core/src/api/common/request-context.spec.ts

@@ -161,7 +161,7 @@ describe('RequestContext', () => {
             token: 'oiajwodij09au3r',
             id: '995859',
             code: '__default_channel__',
-            currencyCode: CurrencyCode.EUR,
+            defaultCurrencyCode: CurrencyCode.EUR,
             pricesIncludeTax: true,
             defaultLanguageCode: LanguageCode.en,
             defaultShippingZone: zone,

+ 9 - 2
packages/core/src/api/common/request-context.ts

@@ -1,4 +1,4 @@
-import { LanguageCode, Permission } from '@vendure/common/lib/generated-types';
+import { CurrencyCode, LanguageCode, Permission } from '@vendure/common/lib/generated-types';
 import { ID, JsonCompatible } from '@vendure/common/lib/shared-types';
 import { isObject } from '@vendure/common/lib/shared-utils';
 import { Request } from 'express';
@@ -43,6 +43,7 @@ export type SerializedRequestContext = {
  */
 export class RequestContext {
     private readonly _languageCode: LanguageCode;
+    private readonly _currencyCode: CurrencyCode;
     private readonly _channel: Channel;
     private readonly _session?: CachedSession;
     private readonly _isAuthorized: boolean;
@@ -60,16 +61,18 @@ export class RequestContext {
         channel: Channel;
         session?: CachedSession;
         languageCode?: LanguageCode;
+        currencyCode?: CurrencyCode;
         isAuthorized: boolean;
         authorizedAsOwnerOnly: boolean;
         translationFn?: TFunction;
     }) {
-        const { req, apiType, channel, session, languageCode, translationFn } = options;
+        const { req, apiType, channel, session, languageCode, currencyCode, translationFn } = options;
         this._req = req;
         this._apiType = apiType;
         this._channel = channel;
         this._session = session;
         this._languageCode = languageCode || (channel && channel.defaultLanguageCode);
+        this._currencyCode = currencyCode || (channel && channel.defaultCurrencyCode);
         this._isAuthorized = options.isAuthorized;
         this._authorizedAsOwnerOnly = options.authorizedAsOwnerOnly;
         this._translationFn = translationFn || (((key: string) => key) as any);
@@ -185,6 +188,10 @@ export class RequestContext {
         return this._languageCode;
     }
 
+    get currencyCode(): CurrencyCode {
+        return this._currencyCode;
+    }
+
     get session(): CachedSession | undefined {
         return this._session;
     }

+ 5 - 0
packages/core/src/api/resolvers/entity/channel-entity.resolver.ts

@@ -16,4 +16,9 @@ export class ChannelEntityResolver {
             ? channel.seller ?? (await this.sellerService.findOne(ctx, channel.sellerId))
             : undefined;
     }
+
+    @ResolveField()
+    currencyCode(@Ctx() ctx: RequestContext, @Parent() channel: Channel): string {
+        return channel.defaultCurrencyCode;
+    }
 }

+ 1 - 1
packages/core/src/config/catalog/product-variant-price-calculation-strategy.ts

@@ -17,7 +17,7 @@ export interface ProductVariantPriceCalculationStrategy extends InjectableStrate
 
 /**
  * @description
- * The arguments passed the the `calculate` method of the configured {@link ProductVariantPriceCalculationStrategy}.
+ * The arguments passed the `calculate` method of the configured {@link ProductVariantPriceCalculationStrategy}.
  *
  * @docsCategory configuration
  * @docsPage ProductVariantPriceCalculationStrategy

+ 1 - 0
packages/core/src/data-import/providers/importer/fast-importer.service.ts

@@ -194,6 +194,7 @@ export class FastImporterService {
             const variantPrice = new ProductVariantPrice({
                 price: input.price,
                 channelId,
+                currencyCode: this.defaultChannel.defaultCurrencyCode,
             });
             variantPrice.variant = createdVariant;
             await this.connection

+ 1 - 1
packages/core/src/entity/channel/channel.entity.ts

@@ -50,7 +50,7 @@ export class Channel extends VendureEntity {
     defaultShippingZone: Zone;
 
     @Column('varchar')
-    currencyCode: CurrencyCode;
+    defaultCurrencyCode: CurrencyCode;
 
     @Column(type => CustomChannelFields)
     customFields: CustomChannelFields;

+ 6 - 2
packages/core/src/entity/product-variant/product-variant-price.entity.ts

@@ -1,3 +1,4 @@
+import { CurrencyCode } from '@vendure/common/lib/generated-types';
 import { DeepPartial, ID } from '@vendure/common/lib/shared-types';
 import { Column, Entity, Index, ManyToOne } from 'typeorm';
 
@@ -22,9 +23,12 @@ export class ProductVariantPrice extends VendureEntity {
 
     @Money() price: number;
 
-    @EntityId() channelId: ID;
+    @EntityId({ nullable: true }) channelId: ID;
+
+    @Column('varchar')
+    currencyCode: CurrencyCode;
 
     @Index()
-    @ManyToOne(type => ProductVariant, variant => variant.productVariantPrices)
+    @ManyToOne(type => ProductVariant, variant => variant.productVariantPrices, { onDelete: 'CASCADE' })
     variant: ProductVariant;
 }

+ 1 - 1
packages/core/src/plugin/default-search-plugin/search-strategy/mysql-search-strategy.ts

@@ -116,7 +116,7 @@ export class MysqlSearchStrategy implements SearchStrategy {
             .limit(take)
             .offset(skip)
             .getRawMany()
-            .then(res => res.map(r => mapToSearchResult(r, ctx.channel.currencyCode)));
+            .then(res => res.map(r => mapToSearchResult(r, ctx.channel.defaultCurrencyCode)));
     }
 
     async getTotalCount(ctx: RequestContext, input: SearchInput, enabledOnly: boolean): Promise<number> {

+ 1 - 1
packages/core/src/plugin/default-search-plugin/search-strategy/postgres-search-strategy.ts

@@ -119,7 +119,7 @@ export class PostgresSearchStrategy implements SearchStrategy {
             .limit(take)
             .offset(skip)
             .getRawMany()
-            .then(res => res.map(r => mapToSearchResult(r, ctx.channel.currencyCode)));
+            .then(res => res.map(r => mapToSearchResult(r, ctx.channel.defaultCurrencyCode)));
     }
 
     async getTotalCount(ctx: RequestContext, input: SearchInput, enabledOnly: boolean): Promise<number> {

+ 1 - 1
packages/core/src/plugin/default-search-plugin/search-strategy/sqlite-search-strategy.ts

@@ -115,7 +115,7 @@ export class SqliteSearchStrategy implements SearchStrategy {
             .limit(take)
             .offset(skip)
             .getRawMany()
-            .then(res => res.map(r => mapToSearchResult(r, ctx.channel.currencyCode)));
+            .then(res => res.map(r => mapToSearchResult(r, ctx.channel.defaultCurrencyCode)));
     }
 
     async getTotalCount(ctx: RequestContext, input: SearchInput, enabledOnly: boolean): Promise<number> {

+ 1 - 1
packages/core/src/service/helpers/product-price-applicator/product-price-applicator.ts

@@ -89,7 +89,7 @@ export class ProductPriceApplicator {
         variant.listPrice = price;
         variant.listPriceIncludesTax = priceIncludesTax;
         variant.taxRateApplied = applicableTaxRate;
-        variant.currencyCode = ctx.channel.currencyCode;
+        variant.currencyCode = channelPrice.currencyCode;
         return variant;
     }
 }

+ 7 - 1
packages/core/src/service/helpers/request-context/request-context.service.ts

@@ -1,5 +1,5 @@
 import { Injectable } from '@nestjs/common';
-import { LanguageCode, Permission } from '@vendure/common/lib/generated-types';
+import { CurrencyCode, LanguageCode, Permission } from '@vendure/common/lib/generated-types';
 import { ID } from '@vendure/common/lib/shared-types';
 import { Request } from 'express';
 import { GraphQLResolveInfo } from 'graphql';
@@ -97,6 +97,7 @@ export class RequestContextService {
 
         const hasOwnerPermission = !!requiredPermissions && requiredPermissions.includes(Permission.Owner);
         const languageCode = this.getLanguageCode(req, channel);
+        const currencyCode = this.getCurrencyCode(req, channel);
         const user = session && session.user;
         const isAuthorized = this.userHasRequiredPermissionsOnChannel(requiredPermissions, channel, user);
         const authorizedAsOwnerOnly = !isAuthorized && hasOwnerPermission;
@@ -106,6 +107,7 @@ export class RequestContextService {
             apiType,
             channel,
             languageCode,
+            currencyCode,
             session,
             isAuthorized,
             authorizedAsOwnerOnly,
@@ -133,6 +135,10 @@ export class RequestContextService {
         );
     }
 
+    private getCurrencyCode(req: Request, channel: Channel): CurrencyCode | undefined {
+        return (req.query && (req.query.currencyCode as CurrencyCode)) ?? channel.defaultCurrencyCode;
+    }
+
     /**
      * TODO: Deprecate and remove, since this function is now handled internally in the RequestContext.
      * @private

+ 5 - 2
packages/core/src/service/services/channel.service.ts

@@ -222,7 +222,10 @@ export class ChannelService {
         ctx: RequestContext,
         input: CreateChannelInput,
     ): Promise<ErrorResultUnion<CreateChannelResult, Channel>> {
-        const channel = new Channel(input);
+        const channel = new Channel({
+            ...input,
+            defaultCurrencyCode: input.currencyCode,
+        });
         const defaultLanguageValidationResult = await this.validateDefaultLanguageCode(ctx, input);
         if (isGraphQlErrorResult(defaultLanguageValidationResult)) {
             return defaultLanguageValidationResult;
@@ -347,7 +350,7 @@ export class ChannelService {
                 code: DEFAULT_CHANNEL_CODE,
                 defaultLanguageCode: this.configService.defaultLanguageCode,
                 pricesIncludeTax: false,
-                currencyCode: CurrencyCode.USD,
+                defaultCurrencyCode: CurrencyCode.USD,
                 token: defaultChannelToken,
             });
         } else if (defaultChannelToken && defaultChannel.token !== defaultChannelToken) {

+ 1 - 1
packages/core/src/service/services/order.service.ts

@@ -474,7 +474,7 @@ export class OrderService {
             billingAddress: {},
             subTotal: 0,
             subTotalWithTax: 0,
-            currencyCode: ctx.channel.currencyCode,
+            currencyCode: ctx.currencyCode,
         });
     }
 

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

@@ -517,6 +517,7 @@ export class ProductVariantService {
             variantPrice = new ProductVariantPrice({
                 channelId,
                 variant: new ProductVariant({ id: productVariantId }),
+                currencyCode: ctx.currencyCode,
             });
         }
         variantPrice.price = price;

+ 1 - 1
packages/elasticsearch-plugin/src/indexing/indexer.controller.ts

@@ -940,7 +940,7 @@ export class ElasticsearchIndexerController implements OnModuleInit, OnModuleDes
             productVariantPreviewFocalPoint: undefined,
             price: 0,
             priceWithTax: 0,
-            currencyCode: ctx.channel.currencyCode,
+            currencyCode: ctx.currencyCode,
             description: productTranslation.description,
             facetIds: product.facetValues?.map(fv => fv.facet.id.toString()) ?? [],
             channelIds: [ctx.channelId],