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

feat(core): Implement TaxLineCalculationStrategy

Relates to #307
Michael Bromley 5 лет назад
Родитель
Сommit
95663b43d0

+ 3 - 1
packages/asset-server-plugin/e2e/graphql/generated-e2e-asset-server-plugin-types.ts

@@ -1553,7 +1553,7 @@ export type ProductVariant = Node & {
     assets: Array<Asset>;
     price: Scalars['Int'];
     currencyCode: CurrencyCode;
-    /** @deprecated price is now always exluding tax */
+    /** @deprecated price now always excludes tax */
     priceIncludesTax: Scalars['Boolean'];
     priceWithTax: Scalars['Int'];
     taxRateApplied: TaxRate;
@@ -3291,6 +3291,8 @@ export enum LanguageCode {
  * by taxRate.
  */
 export type OrderTaxSummary = {
+    /** A description of this tax */
+    description: Scalars['String'];
     /** The taxRate as a percentage */
     taxRate: Scalars['Float'];
     /** The total net price or OrderItems to which this taxRate applies */

+ 3 - 1
packages/common/src/generated-shop-types.ts

@@ -1765,6 +1765,8 @@ export type OrderHistoryArgs = {
  */
 export type OrderTaxSummary = {
     __typename?: 'OrderTaxSummary';
+    /** A description of this tax */
+    description: Scalars['String'];
     /** The taxRate as a percentage */
     taxRate: Scalars['Float'];
     /** The total net price or OrderItems to which this taxRate applies */
@@ -2123,7 +2125,7 @@ export type ProductVariant = Node & {
     assets: Array<Asset>;
     price: Scalars['Int'];
     currencyCode: CurrencyCode;
-    /** @deprecated price is now always exluding tax */
+    /** @deprecated price now always excludes tax */
     priceIncludesTax: Scalars['Boolean'];
     priceWithTax: Scalars['Int'];
     taxRateApplied: TaxRate;

+ 3 - 1
packages/common/src/generated-types.ts

@@ -1705,7 +1705,7 @@ export type ProductVariant = Node & {
   assets: Array<Asset>;
   price: Scalars['Int'];
   currencyCode: CurrencyCode;
-  /** @deprecated price is now always exluding tax */
+  /** @deprecated price now always excludes tax */
   priceIncludesTax: Scalars['Boolean'];
   priceWithTax: Scalars['Int'];
   taxRateApplied: TaxRate;
@@ -3493,6 +3493,8 @@ export enum LanguageCode {
  */
 export type OrderTaxSummary = {
   __typename?: 'OrderTaxSummary';
+  /** A description of this tax */
+  description: Scalars['String'];
   /** The taxRate as a percentage */
   taxRate: Scalars['Float'];
   /** The total net price or OrderItems to which this taxRate applies */

+ 3 - 1
packages/core/e2e/graphql/generated-e2e-admin-types.ts

@@ -1553,7 +1553,7 @@ export type ProductVariant = Node & {
     assets: Array<Asset>;
     price: Scalars['Int'];
     currencyCode: CurrencyCode;
-    /** @deprecated price is now always exluding tax */
+    /** @deprecated price now always excludes tax */
     priceIncludesTax: Scalars['Boolean'];
     priceWithTax: Scalars['Int'];
     taxRateApplied: TaxRate;
@@ -3291,6 +3291,8 @@ export enum LanguageCode {
  * by taxRate.
  */
 export type OrderTaxSummary = {
+    /** A description of this tax */
+    description: Scalars['String'];
     /** The taxRate as a percentage */
     taxRate: Scalars['Float'];
     /** The total net price or OrderItems to which this taxRate applies */

+ 4 - 2
packages/core/e2e/graphql/generated-e2e-shop-types.ts

@@ -1713,6 +1713,8 @@ export type OrderHistoryArgs = {
  * by taxRate.
  */
 export type OrderTaxSummary = {
+    /** A description of this tax */
+    description: Scalars['String'];
     /** The taxRate as a percentage */
     taxRate: Scalars['Float'];
     /** The total net price or OrderItems to which this taxRate applies */
@@ -2047,7 +2049,7 @@ export type ProductVariant = Node & {
     assets: Array<Asset>;
     price: Scalars['Int'];
     currencyCode: CurrencyCode;
-    /** @deprecated price is now always exluding tax */
+    /** @deprecated price now always excludes tax */
     priceIncludesTax: Scalars['Boolean'];
     priceWithTax: Scalars['Int'];
     taxRateApplied: TaxRate;
@@ -2829,7 +2831,7 @@ export type GetActiveOrderWithPriceDataQuery = {
                     taxLines: Array<Pick<TaxLine, 'taxRate' | 'description'>>;
                 }
             >;
-            taxSummary: Array<Pick<OrderTaxSummary, 'taxRate' | 'taxBase' | 'taxTotal'>>;
+            taxSummary: Array<Pick<OrderTaxSummary, 'description' | 'taxRate' | 'taxBase' | 'taxTotal'>>;
         }
     >;
 };

+ 1 - 0
packages/core/e2e/graphql/shop-definitions.ts

@@ -338,6 +338,7 @@ export const GET_ACTIVE_ORDER_WITH_PRICE_DATA = gql`
                 }
             }
             taxSummary {
+                description
                 taxRate
                 taxBase
                 taxTotal

+ 3 - 0
packages/core/e2e/order-taxes.e2e-spec.ts

@@ -159,16 +159,19 @@ describe('Order taxes', () => {
 
         expect(activeOrder?.taxSummary).toEqual([
             {
+                description: 'Standard Tax Europe',
                 taxRate: 20,
                 taxBase: 200,
                 taxTotal: 40,
             },
             {
+                description: 'Reduced Tax Europe',
                 taxRate: 10,
                 taxBase: 200,
                 taxTotal: 20,
             },
             {
+                description: 'Zero Tax Europe',
                 taxRate: 0,
                 taxBase: 200,
                 taxTotal: 0,

+ 2 - 0
packages/core/src/api/schema/common/order.type.graphql

@@ -59,6 +59,8 @@ A summary of the taxes being applied to this order, grouped
 by taxRate.
 """
 type OrderTaxSummary {
+    "A description of this tax"
+    description: String!
     "The taxRate as a percentage"
     taxRate: Float!
     "The total net price or OrderItems to which this taxRate applies"

+ 1 - 1
packages/core/src/config/config.service.ts

@@ -87,7 +87,7 @@ export class ConfigService implements VendureConfig {
         return this.activeConfig.paymentOptions;
     }
 
-    get taxOptions(): TaxOptions {
+    get taxOptions(): Required<TaxOptions> {
         return this.activeConfig.taxOptions;
     }
 

+ 2 - 0
packages/core/src/config/default-config.ts

@@ -26,6 +26,7 @@ import { defaultPromotionActions, defaultPromotionConditions } from './promotion
 import { InMemorySessionCacheStrategy } from './session-cache/in-memory-session-cache-strategy';
 import { defaultShippingCalculator } from './shipping-method/default-shipping-calculator';
 import { defaultShippingEligibilityChecker } from './shipping-method/default-shipping-eligibility-checker';
+import { DefaultTaxLineCalculationStrategy } from './tax/default-tax-line-calculation-strategy';
 import { DefaultTaxZoneStrategy } from './tax/default-tax-zone-strategy';
 import { RuntimeVendureConfig } from './vendure-config';
 
@@ -118,6 +119,7 @@ export const defaultConfig: RuntimeVendureConfig = {
     },
     taxOptions: {
         taxZoneStrategy: new DefaultTaxZoneStrategy(),
+        taxLineCalculationStrategy: new DefaultTaxLineCalculationStrategy(),
     },
     importExportOptions: {
         importAssetsDir: __dirname,

+ 17 - 0
packages/core/src/config/tax/default-tax-line-calculation-strategy.ts

@@ -0,0 +1,17 @@
+import { TaxLine } from '@vendure/common/lib/generated-types';
+
+import { CalculateTaxLinesArgs, TaxLineCalculationStrategy } from './tax-line-calculation-strategy';
+
+/**
+ * @description
+ * The default {@link TaxLineCalculationStrategy} which applies a single TaxLine to the OrderItem
+ * based on the applicable {@link TaxRate}.
+ *
+ * @docsCategory tax
+ */
+export class DefaultTaxLineCalculationStrategy implements TaxLineCalculationStrategy {
+    calculate(args: CalculateTaxLinesArgs): TaxLine[] {
+        const { orderItem, applicableTaxRate } = args;
+        return [applicableTaxRate.apply(orderItem.proratedUnitPrice)];
+    }
+}

+ 47 - 0
packages/core/src/config/tax/tax-line-calculation-strategy.ts

@@ -0,0 +1,47 @@
+import { TaxLine } from '@vendure/common/lib/generated-types';
+
+import { RequestContext } from '../../api/common/request-context';
+import { InjectableStrategy } from '../../common/types/injectable-strategy';
+import { OrderItem } from '../../entity/order-item/order-item.entity';
+import { OrderLine } from '../../entity/order-line/order-line.entity';
+import { Order } from '../../entity/order/order.entity';
+import { TaxRate } from '../../entity/tax-rate/tax-rate.entity';
+
+/**
+ * @description
+ * This strategy defines how the TaxLines on OrderItems are calculated. By default,
+ * the {@link DefaultTaxLineCalculationStrategy} is used, which directly applies
+ * a single TaxLine based on the applicable {@link TaxRate}.
+ *
+ * However, custom strategies may use any suitable method for calculating TaxLines.
+ * For example, a third-party tax API or a lookup of a custom tax table may be used.
+ *
+ * @docsCategory tax
+ * @docsPage TaxLineCalculationStrategy
+ * @docsWeight 0
+ */
+export interface TaxLineCalculationStrategy extends InjectableStrategy {
+    /**
+     * @description
+     * This method is called when calculating the Order prices. Since it will be called
+     * whenever an Order is modified in some way (adding/removing items, applying promotions,
+     * setting ShippingMethod etc), care should be taken so that calling the function does
+     * not adversely impact overall performance. For example, by using caching and only
+     * calling external APIs when absolutely necessary.
+     */
+    calculate(args: CalculateTaxLinesArgs): TaxLine[] | Promise<TaxLine[]>;
+}
+
+/**
+ * @description
+ *
+ * @docsCategory tax
+ * @docsPage TaxLineCalculationStrategy
+ */
+export interface CalculateTaxLinesArgs {
+    ctx: RequestContext;
+    order: Order;
+    orderLine: OrderLine;
+    orderItem: OrderItem;
+    applicableTaxRate: TaxRate;
+}

+ 10 - 1
packages/core/src/config/vendure-config.ts

@@ -31,6 +31,7 @@ import { PromotionCondition } from './promotion/promotion-condition';
 import { SessionCacheStrategy } from './session-cache/session-cache-strategy';
 import { ShippingCalculator } from './shipping-method/shipping-calculator';
 import { ShippingEligibilityChecker } from './shipping-method/shipping-eligibility-checker';
+import { TaxLineCalculationStrategy } from './tax/tax-line-calculation-strategy';
 import { TaxZoneStrategy } from './tax/tax-zone-strategy';
 
 /**
@@ -599,7 +600,14 @@ export interface TaxOptions {
      *
      * @default DefaultTaxZoneStrategy
      */
-    taxZoneStrategy: TaxZoneStrategy;
+    taxZoneStrategy?: TaxZoneStrategy;
+    /**
+     * @description
+     * Defines the strategy used to calculate the TaxLines added to OrderItems.
+     *
+     * @default DefaultTaxLineCalculationStrategy
+     */
+    taxLineCalculationStrategy?: TaxLineCalculationStrategy;
 }
 
 /**
@@ -840,6 +848,7 @@ export interface RuntimeVendureConfig extends Required<VendureConfig> {
     promotionOptions: Required<PromotionOptions>;
     shippingOptions: Required<ShippingOptions>;
     workerOptions: Required<WorkerOptions>;
+    taxOptions: Required<TaxOptions>;
 }
 
 type DeepPartialSimple<T> = {

+ 291 - 0
packages/core/src/entity/order/order.entity.spec.ts

@@ -0,0 +1,291 @@
+import { AdjustmentType } from '@vendure/common/lib/generated-types';
+import { summate } from '@vendure/common/lib/shared-utils';
+
+import { createOrder, createRequestContext, taxCategoryStandard } from '../../testing/order-test-utils';
+
+import { Order } from './order.entity';
+
+describe('Order entity methods', () => {
+    describe('taxSummary', () => {
+        it('single rate across items', () => {
+            const ctx = createRequestContext({ pricesIncludeTax: false });
+            const order = createOrder({
+                ctx,
+                lines: [
+                    {
+                        listPrice: 300,
+                        taxCategory: taxCategoryStandard,
+                        quantity: 2,
+                    },
+                    {
+                        listPrice: 1000,
+                        taxCategory: taxCategoryStandard,
+                        quantity: 1,
+                    },
+                ],
+            });
+            order.lines[0].items.forEach(i => (i.taxLines = [{ taxRate: 5, description: 'tax a' }]));
+            order.lines[1].items.forEach(i => (i.taxLines = [{ taxRate: 5, description: 'tax a' }]));
+
+            expect(order.taxSummary).toEqual([
+                {
+                    description: 'tax a',
+                    taxRate: 5,
+                    taxBase: 1600,
+                    taxTotal: 80,
+                },
+            ]);
+            assertOrderTaxesAddsUp(order);
+        });
+
+        it('different rate on each item', () => {
+            const ctx = createRequestContext({ pricesIncludeTax: false });
+            const order = createOrder({
+                ctx,
+                lines: [
+                    {
+                        listPrice: 300,
+                        taxCategory: taxCategoryStandard,
+                        quantity: 2,
+                    },
+                    {
+                        listPrice: 1000,
+                        taxCategory: taxCategoryStandard,
+                        quantity: 1,
+                    },
+                ],
+            });
+            order.lines[0].items.forEach(i => (i.taxLines = [{ taxRate: 5, description: 'tax a' }]));
+            order.lines[1].items.forEach(i => (i.taxLines = [{ taxRate: 7.5, description: 'tax b' }]));
+
+            expect(order.taxSummary).toEqual([
+                {
+                    description: 'tax a',
+                    taxRate: 5,
+                    taxBase: 600,
+                    taxTotal: 30,
+                },
+                {
+                    description: 'tax b',
+                    taxRate: 7.5,
+                    taxBase: 1000,
+                    taxTotal: 75,
+                },
+            ]);
+            assertOrderTaxesAddsUp(order);
+        });
+
+        it('multiple rates on each item', () => {
+            const ctx = createRequestContext({ pricesIncludeTax: false });
+            const order = createOrder({
+                ctx,
+                lines: [
+                    {
+                        listPrice: 300,
+                        taxCategory: taxCategoryStandard,
+                        quantity: 2,
+                    },
+                    {
+                        listPrice: 1000,
+                        taxCategory: taxCategoryStandard,
+                        quantity: 1,
+                    },
+                ],
+            });
+            order.lines[0].items.forEach(
+                i =>
+                    (i.taxLines = [
+                        { taxRate: 5, description: 'tax a' },
+                        { taxRate: 7.5, description: 'tax b' },
+                    ]),
+            );
+            order.lines[1].items.forEach(
+                i =>
+                    (i.taxLines = [
+                        { taxRate: 5, description: 'tax a' },
+                        { taxRate: 7.5, description: 'tax b' },
+                    ]),
+            );
+
+            expect(order.taxSummary).toEqual([
+                {
+                    description: 'tax a',
+                    taxRate: 5,
+                    taxBase: 1600,
+                    taxTotal: 80,
+                },
+                {
+                    description: 'tax b',
+                    taxRate: 7.5,
+                    taxBase: 1600,
+                    taxTotal: 121,
+                },
+            ]);
+            assertOrderTaxesAddsUp(order);
+        });
+
+        it('multiple rates on each item, prorated order discount', () => {
+            const ctx = createRequestContext({ pricesIncludeTax: false });
+            const order = createOrder({
+                ctx,
+                lines: [
+                    {
+                        listPrice: 300,
+                        taxCategory: taxCategoryStandard,
+                        quantity: 2,
+                    },
+                    {
+                        listPrice: 1000,
+                        taxCategory: taxCategoryStandard,
+                        quantity: 1,
+                    },
+                ],
+            });
+            order.lines[0].items.forEach(i => {
+                i.taxLines = [
+                    { taxRate: 5, description: 'tax a' },
+                    { taxRate: 7.5, description: 'tax b' },
+                ];
+                i.adjustments = [
+                    {
+                        amount: -30,
+                        adjustmentSource: 'some order discount',
+                        description: 'some order discount',
+                        type: AdjustmentType.DISTRIBUTED_ORDER_PROMOTION,
+                    },
+                ];
+            });
+            order.lines[1].items.forEach(i => {
+                i.taxLines = [
+                    { taxRate: 5, description: 'tax a' },
+                    { taxRate: 7.5, description: 'tax b' },
+                ];
+                i.adjustments = [
+                    {
+                        amount: -100,
+                        adjustmentSource: 'some order discount',
+                        description: 'some order discount',
+                        type: AdjustmentType.DISTRIBUTED_ORDER_PROMOTION,
+                    },
+                ];
+            });
+
+            expect(order.taxSummary).toEqual([
+                {
+                    description: 'tax a',
+                    taxRate: 5,
+                    taxBase: 1440,
+                    taxTotal: 72,
+                },
+                {
+                    description: 'tax b',
+                    taxRate: 7.5,
+                    taxBase: 1440,
+                    taxTotal: 109,
+                },
+            ]);
+            assertOrderTaxesAddsUp(order);
+        });
+
+        it('multiple rates on each item, item discount, prorated order discount', () => {
+            const ctx = createRequestContext({ pricesIncludeTax: false });
+            const order = createOrder({
+                ctx,
+                lines: [
+                    {
+                        listPrice: 300,
+                        taxCategory: taxCategoryStandard,
+                        quantity: 2,
+                    },
+                    {
+                        listPrice: 1000,
+                        taxCategory: taxCategoryStandard,
+                        quantity: 1,
+                    },
+                ],
+            });
+            order.lines[0].items.forEach(i => {
+                i.taxLines = [
+                    { taxRate: 5, description: 'tax a' },
+                    { taxRate: 7.5, description: 'tax b' },
+                ];
+                i.adjustments = [
+                    {
+                        amount: -30,
+                        adjustmentSource: 'some order discount',
+                        description: 'some order discount',
+                        type: AdjustmentType.DISTRIBUTED_ORDER_PROMOTION,
+                    },
+                    {
+                        amount: -125,
+                        adjustmentSource: 'some item discount',
+                        description: 'some item discount',
+                        type: AdjustmentType.PROMOTION,
+                    },
+                ];
+            });
+            order.lines[1].items.forEach(i => {
+                i.taxLines = [
+                    { taxRate: 5, description: 'tax a' },
+                    { taxRate: 7.5, description: 'tax b' },
+                ];
+                i.adjustments = [
+                    {
+                        amount: -100,
+                        adjustmentSource: 'some order discount',
+                        description: 'some order discount',
+                        type: AdjustmentType.DISTRIBUTED_ORDER_PROMOTION,
+                    },
+                ];
+            });
+
+            expect(order.taxSummary).toEqual([
+                {
+                    description: 'tax a',
+                    taxRate: 5,
+                    taxBase: 1190,
+                    taxTotal: 59,
+                },
+                {
+                    description: 'tax b',
+                    taxRate: 7.5,
+                    taxBase: 1190,
+                    taxTotal: 90,
+                },
+            ]);
+            assertOrderTaxesAddsUp(order);
+        });
+
+        it('zero rate', () => {
+            const ctx = createRequestContext({ pricesIncludeTax: false });
+            const order = createOrder({
+                ctx,
+                lines: [
+                    {
+                        listPrice: 300,
+                        taxCategory: taxCategoryStandard,
+                        quantity: 2,
+                    },
+                ],
+            });
+            order.lines[0].items.forEach(i => (i.taxLines = [{ taxRate: 0, description: 'zero-rate' }]));
+
+            expect(order.taxSummary).toEqual([
+                {
+                    description: 'zero-rate',
+                    taxRate: 0,
+                    taxBase: 600,
+                    taxTotal: 0,
+                },
+            ]);
+            assertOrderTaxesAddsUp(order);
+        });
+    });
+});
+
+function assertOrderTaxesAddsUp(order: Order) {
+    const summaryTaxTotal = summate(order.taxSummary, 'taxTotal');
+    const lineTotal = summate(order.lines, 'proratedLinePrice');
+    const lineTotalWithTax = summate(order.lines, 'proratedLinePriceWithTax');
+    expect(lineTotalWithTax - lineTotal).toBe(summaryTaxTotal);
+}

+ 34 - 12
packages/core/src/entity/order/order.entity.ts

@@ -1,9 +1,16 @@
-import { Adjustment, CurrencyCode, OrderAddress, OrderTaxSummary } from '@vendure/common/lib/generated-types';
+import {
+    Adjustment,
+    CurrencyCode,
+    OrderAddress,
+    OrderTaxSummary,
+    TaxLine,
+} from '@vendure/common/lib/generated-types';
 import { DeepPartial, ID } from '@vendure/common/lib/shared-types';
 import { summate } from '@vendure/common/lib/shared-utils';
 import { Column, Entity, JoinTable, ManyToMany, ManyToOne, OneToMany } from 'typeorm';
 
 import { Calculated } from '../../common/calculated-decorator';
+import { taxPayableOn } from '../../common/tax-utils';
 import { ChannelAware } from '../../common/types/common-types';
 import { HasCustomFields } from '../../config/custom-field/custom-field-types';
 import { OrderState } from '../../service/helpers/order-state-machine/order-state';
@@ -137,21 +144,36 @@ export class Order extends VendureEntity implements ChannelAware, HasCustomField
 
     @Calculated()
     get taxSummary(): OrderTaxSummary[] {
-        const taxRateMap = new Map<number, { base: number; tax: number }>();
+        const taxRateMap = new Map<
+            string,
+            { rate: number; base: number; tax: number; description: string }
+        >();
+        const taxId = (taxLine: TaxLine): string => `${taxLine.description}:${taxLine.taxRate}`;
         for (const line of this.lines) {
-            const row = taxRateMap.get(line.taxRate);
-            if (row) {
-                row.tax += line.proratedLineTax;
-                row.base += line.proratedLinePrice;
-            } else {
-                taxRateMap.set(line.taxRate, {
-                    tax: line.proratedLineTax,
-                    base: line.proratedLinePrice,
-                });
+            const taxRateTotal = summate(line.taxLines, 'taxRate');
+            for (const taxLine of line.taxLines) {
+                const id = taxId(taxLine);
+                const row = taxRateMap.get(id);
+                const proportionOfTotalRate = 0 < taxLine.taxRate ? taxLine.taxRate / taxRateTotal : 0;
+                const amount = Math.round(
+                    (line.proratedLinePriceWithTax - line.proratedLinePrice) * proportionOfTotalRate,
+                );
+                if (row) {
+                    row.tax += amount;
+                    row.base += line.proratedLinePrice;
+                } else {
+                    taxRateMap.set(id, {
+                        tax: amount,
+                        base: line.proratedLinePrice,
+                        description: taxLine.description,
+                        rate: taxLine.taxRate,
+                    });
+                }
             }
         }
         return Array.from(taxRateMap.entries()).map(([taxRate, row]) => ({
-            taxRate,
+            taxRate: row.rate,
+            description: row.description,
             taxBase: row.base,
             taxTotal: row.tax,
         }));

+ 218 - 96
packages/core/src/service/helpers/order-calculator/order-calculator.spec.ts

@@ -1,23 +1,25 @@
 import { Test } from '@nestjs/testing';
-import { AdjustmentType, LanguageCode } from '@vendure/common/lib/generated-types';
-import { Omit } from '@vendure/common/lib/omit';
+import { AdjustmentType, LanguageCode, TaxLine } from '@vendure/common/lib/generated-types';
 import { summate } from '@vendure/common/lib/shared-utils';
 
 import { RequestContext } from '../../../api/common/request-context';
 import { PromotionItemAction, PromotionOrderAction, PromotionShippingAction } from '../../../config';
-import { DefaultProductVariantPriceCalculationStrategy } from '../../../config/catalog/default-product-variant-price-calculation-strategy';
 import { ConfigService } from '../../../config/config.service';
 import { MockConfigService } from '../../../config/config.service.mock';
 import { PromotionCondition } from '../../../config/promotion/promotion-condition';
+import { DefaultTaxLineCalculationStrategy } from '../../../config/tax/default-tax-line-calculation-strategy';
 import { DefaultTaxZoneStrategy } from '../../../config/tax/default-tax-zone-strategy';
+import {
+    CalculateTaxLinesArgs,
+    TaxLineCalculationStrategy,
+} from '../../../config/tax/tax-line-calculation-strategy';
 import { Promotion } from '../../../entity';
 import { OrderItem } from '../../../entity/order-item/order-item.entity';
-import { OrderLine } from '../../../entity/order-line/order-line.entity';
 import { Order } from '../../../entity/order/order.entity';
 import { ShippingLine } from '../../../entity/shipping-line/shipping-line.entity';
-import { TaxCategory } from '../../../entity/tax-category/tax-category.entity';
 import { EventBus } from '../../../event-bus/event-bus';
 import {
+    createOrder,
     createRequestContext,
     MockTaxRateService,
     taxCategoryReduced,
@@ -32,50 +34,19 @@ import { ShippingCalculator } from '../shipping-calculator/shipping-calculator';
 
 import { OrderCalculator } from './order-calculator';
 
+const mockShippingMethodId = 'T_1';
+
 describe('OrderCalculator', () => {
     let orderCalculator: OrderCalculator;
 
-    const mockShippingMethodId = 'T_1';
-    function createMockShippingMethod(ctx: RequestContext) {
-        return {
-            id: mockShippingMethodId,
-            test: () => true,
-            apply() {
-                return {
-                    price: 500,
-                    priceIncludesTax: ctx.channel.pricesIncludeTax,
-                    taxRate: 20,
-                };
-            },
-        };
-    }
-
-    beforeEach(async () => {
-        const module = await Test.createTestingModule({
-            providers: [
-                OrderCalculator,
-                { provide: TaxRateService, useClass: MockTaxRateService },
-                { provide: ShippingCalculator, useValue: { getEligibleShippingMethods: () => [] } },
-                {
-                    provide: ShippingMethodService,
-                    useValue: { findOne: (ctx: RequestContext) => createMockShippingMethod(ctx) },
-                },
-                { provide: ListQueryBuilder, useValue: {} },
-                { provide: ConfigService, useClass: MockConfigService },
-                { provide: EventBus, useValue: { publish: () => ({}) } },
-                { provide: WorkerService, useValue: { send: () => ({}) } },
-                { provide: ZoneService, useValue: { findAll: () => [] } },
-            ],
-        }).compile();
-
+    beforeAll(async () => {
+        const module = await createTestModule();
         orderCalculator = module.get(OrderCalculator);
         const mockConfigService = module.get<ConfigService, MockConfigService>(ConfigService);
         mockConfigService.taxOptions = {
             taxZoneStrategy: new DefaultTaxZoneStrategy(),
-            taxCalculationStrategy: new DefaultProductVariantPriceCalculationStrategy(),
+            taxLineCalculationStrategy: new DefaultTaxLineCalculationStrategy(),
         };
-        const taxRateService = module.get(TaxRateService);
-        await taxRateService.initTaxRates();
     });
 
     describe('taxes', () => {
@@ -1097,65 +1068,216 @@ describe('OrderCalculator', () => {
             });
         });
     });
+});
 
-    function createOrder(
-        orderConfig: Partial<Omit<Order, 'lines'>> & {
-            ctx: RequestContext;
-            lines: Array<{
-                listPrice: number;
-                taxCategory: TaxCategory;
-                quantity: number;
-            }>;
-        },
-    ): Order {
-        const lines = orderConfig.lines.map(
-            ({ listPrice, taxCategory, quantity }) =>
-                new OrderLine({
-                    taxCategory,
-                    items: Array.from({ length: quantity }).map(
-                        () =>
-                            new OrderItem({
-                                listPrice,
-                                listPriceIncludesTax: orderConfig.ctx.channel.pricesIncludeTax,
-                                taxLines: [],
-                                adjustments: [],
-                            }),
-                    ),
-                }),
-        );
-
-        return new Order({
-            couponCodes: [],
-            lines,
-            shippingLines: [],
-        });
-    }
+describe('OrderCalculator with custom TaxLineCalculationStrategy', () => {
+    let orderCalculator: OrderCalculator;
+    const newYorkStateTaxLine: TaxLine = {
+        description: 'New York state sales tax',
+        taxRate: 4,
+    };
+    const nycCityTaxLine: TaxLine = {
+        description: 'NYC sales tax',
+        taxRate: 4.5,
+    };
 
     /**
-     * Make sure that the properties which will be displayed to the customer add up in a consistent way.
+     * This strategy uses a completely custom method of calculation based on the Order
+     * shipping address, potentially adding multiple TaxLines. This is intended to simulate
+     * tax handling as in the US where multiple tax rates can apply based on location data.
      */
-    function assertOrderTotalsAddUp(order: Order) {
-        for (const line of order.lines) {
-            const itemUnitPriceSum = summate(line.items, 'unitPrice');
-            expect(line.linePrice).toBe(itemUnitPriceSum);
-            const itemUnitPriceWithTaxSum = summate(line.items, 'unitPriceWithTax');
-            expect(line.linePriceWithTax).toBe(itemUnitPriceWithTaxSum);
+    class CustomTaxLineCalculationStrategy implements TaxLineCalculationStrategy {
+        calculate(args: CalculateTaxLinesArgs): Promise<TaxLine[]> {
+            const { order } = args;
+            const taxLines: TaxLine[] = [];
+            if (order.shippingAddress?.province === 'New York') {
+                taxLines.push(newYorkStateTaxLine);
+                if (order.shippingAddress?.city === 'New York City') {
+                    taxLines.push(nycCityTaxLine);
+                }
+            }
+
+            // Return a promise to simulate having called out to
+            // and external tax API
+            return Promise.resolve(taxLines);
         }
-        const taxableLinePriceSum = summate(order.lines, 'proratedLinePrice');
-        expect(order.subTotal).toBe(taxableLinePriceSum);
-
-        // Make sure the customer-facing totals also add up
-        const displayPriceWithTaxSum = summate(order.lines, 'discountedLinePriceWithTax');
-        const orderDiscountsSum = order.discounts
-            .filter(d => d.type === AdjustmentType.DISTRIBUTED_ORDER_PROMOTION)
-            .reduce((sum, d) => sum + d.amount, 0);
-
-        // The sum of the display prices + order discounts should in theory exactly
-        // equal the subTotalWithTax. In practice, there are occasionally 1cent differences
-        // cause by rounding errors. This should be tolerable.
-        const differenceBetweenSumAndActual = Math.abs(
-            displayPriceWithTaxSum + orderDiscountsSum - order.subTotalWithTax,
-        );
-        expect(differenceBetweenSumAndActual).toBeLessThanOrEqual(1);
     }
+
+    beforeAll(async () => {
+        const module = await createTestModule();
+        orderCalculator = module.get(OrderCalculator);
+        const mockConfigService = module.get<ConfigService, MockConfigService>(ConfigService);
+        mockConfigService.taxOptions = {
+            taxZoneStrategy: new DefaultTaxZoneStrategy(),
+            taxLineCalculationStrategy: new CustomTaxLineCalculationStrategy(),
+        };
+    });
+
+    it('no TaxLines applied', async () => {
+        const ctx = createRequestContext({ pricesIncludeTax: false });
+        const order = createOrder({
+            ctx,
+            lines: [
+                {
+                    listPrice: 1000,
+                    taxCategory: taxCategoryStandard,
+                    quantity: 2,
+                },
+                {
+                    listPrice: 3499,
+                    taxCategory: taxCategoryReduced,
+                    quantity: 1,
+                },
+            ],
+        });
+        await orderCalculator.applyPriceAdjustments(ctx, order, []);
+
+        expect(order.subTotal).toBe(5499);
+        expect(order.subTotalWithTax).toBe(5499);
+        expect(order.taxSummary).toEqual([]);
+        expect(order.lines[0].taxLines).toEqual([]);
+        expect(order.lines[1].taxLines).toEqual([]);
+        assertOrderTotalsAddUp(order);
+    });
+
+    it('single TaxLines applied', async () => {
+        const ctx = createRequestContext({ pricesIncludeTax: false });
+        const order = createOrder({
+            ctx,
+            lines: [
+                {
+                    listPrice: 1000,
+                    taxCategory: taxCategoryStandard,
+                    quantity: 2,
+                },
+                {
+                    listPrice: 3499,
+                    taxCategory: taxCategoryReduced,
+                    quantity: 1,
+                },
+            ],
+        });
+        order.shippingAddress = {
+            city: 'Rochester',
+            province: 'New York',
+        };
+        await orderCalculator.applyPriceAdjustments(ctx, order, []);
+
+        expect(order.subTotal).toBe(5499);
+        expect(order.subTotalWithTax).toBe(5719);
+        expect(order.taxSummary).toEqual([
+            {
+                description: newYorkStateTaxLine.description,
+                taxRate: newYorkStateTaxLine.taxRate,
+                taxBase: 5499,
+                taxTotal: 220,
+            },
+        ]);
+        expect(order.lines[0].taxLines).toEqual([newYorkStateTaxLine]);
+        expect(order.lines[1].taxLines).toEqual([newYorkStateTaxLine]);
+        assertOrderTotalsAddUp(order);
+    });
+
+    it('multiple TaxLines applied', async () => {
+        const ctx = createRequestContext({ pricesIncludeTax: false });
+        const order = createOrder({
+            ctx,
+            lines: [
+                {
+                    listPrice: 1000,
+                    taxCategory: taxCategoryStandard,
+                    quantity: 2,
+                },
+                {
+                    listPrice: 3499,
+                    taxCategory: taxCategoryReduced,
+                    quantity: 1,
+                },
+            ],
+        });
+        order.shippingAddress = {
+            city: 'New York City',
+            province: 'New York',
+        };
+        await orderCalculator.applyPriceAdjustments(ctx, order, []);
+
+        expect(order.subTotal).toBe(5499);
+        expect(order.subTotalWithTax).toBe(5966);
+        expect(order.taxSummary).toEqual([
+            {
+                description: newYorkStateTaxLine.description,
+                taxRate: newYorkStateTaxLine.taxRate,
+                taxBase: 5499,
+                taxTotal: 220,
+            },
+            {
+                description: nycCityTaxLine.description,
+                taxRate: nycCityTaxLine.taxRate,
+                taxBase: 5499,
+                taxTotal: 247,
+            },
+        ]);
+        expect(order.lines[0].taxLines).toEqual([newYorkStateTaxLine, nycCityTaxLine]);
+        expect(order.lines[1].taxLines).toEqual([newYorkStateTaxLine, nycCityTaxLine]);
+        assertOrderTotalsAddUp(order);
+    });
 });
+
+function createTestModule() {
+    return Test.createTestingModule({
+        providers: [
+            OrderCalculator,
+            { provide: TaxRateService, useClass: MockTaxRateService },
+            { provide: ShippingCalculator, useValue: { getEligibleShippingMethods: () => [] } },
+            {
+                provide: ShippingMethodService,
+                useValue: {
+                    findOne: (ctx: RequestContext) => ({
+                        id: mockShippingMethodId,
+                        test: () => true,
+                        apply() {
+                            return {
+                                price: 500,
+                                priceIncludesTax: ctx.channel.pricesIncludeTax,
+                                taxRate: 20,
+                            };
+                        },
+                    }),
+                },
+            },
+            { provide: ListQueryBuilder, useValue: {} },
+            { provide: ConfigService, useClass: MockConfigService },
+            { provide: EventBus, useValue: { publish: () => ({}) } },
+            { provide: WorkerService, useValue: { send: () => ({}) } },
+            { provide: ZoneService, useValue: { findAll: () => [] } },
+        ],
+    }).compile();
+}
+
+/**
+ * Make sure that the properties which will be displayed to the customer add up in a consistent way.
+ */
+function assertOrderTotalsAddUp(order: Order) {
+    for (const line of order.lines) {
+        const itemUnitPriceSum = summate(line.items, 'unitPrice');
+        expect(line.linePrice).toBe(itemUnitPriceSum);
+        const itemUnitPriceWithTaxSum = summate(line.items, 'unitPriceWithTax');
+        expect(line.linePriceWithTax).toBe(itemUnitPriceWithTaxSum);
+    }
+    const taxableLinePriceSum = summate(order.lines, 'proratedLinePrice');
+    expect(order.subTotal).toBe(taxableLinePriceSum);
+
+    // Make sure the customer-facing totals also add up
+    const displayPriceWithTaxSum = summate(order.lines, 'discountedLinePriceWithTax');
+    const orderDiscountsSum = order.discounts
+        .filter(d => d.type === AdjustmentType.DISTRIBUTED_ORDER_PROMOTION)
+        .reduce((sum, d) => sum + d.amount, 0);
+
+    // The sum of the display prices + order discounts should in theory exactly
+    // equal the subTotalWithTax. In practice, there are occasionally 1cent differences
+    // cause by rounding errors. This should be tolerable.
+    const differenceBetweenSumAndActual = Math.abs(
+        displayPriceWithTaxSum + orderDiscountsSum - order.subTotalWithTax,
+    );
+    expect(differenceBetweenSumAndActual).toBeLessThanOrEqual(1);
+}

+ 16 - 7
packages/core/src/service/helpers/order-calculator/order-calculator.ts

@@ -53,8 +53,9 @@ export class OrderCalculator {
         }
         const updatedOrderItems = new Set<OrderItem>();
         if (updatedOrderLine) {
-            this.applyTaxesToOrderLine(
+            await this.applyTaxesToOrderLine(
                 ctx,
+                order,
                 updatedOrderLine,
                 activeTaxZone,
                 this.createTaxRateGetter(activeTaxZone),
@@ -65,7 +66,7 @@ export class OrderCalculator {
         if (order.lines.length) {
             if (taxZoneChanged) {
                 // First apply taxes to the non-discounted prices
-                this.applyTaxes(ctx, order, activeTaxZone);
+                await this.applyTaxes(ctx, order, activeTaxZone);
             }
 
             // Then test and apply promotions
@@ -76,7 +77,7 @@ export class OrderCalculator {
             if (order.subTotal !== totalBeforePromotions || itemsModifiedByPromotions.length) {
                 // Finally, re-calculate taxes because the promotions may have
                 // altered the unit prices, which in turn will alter the tax payable.
-                this.applyTaxes(ctx, order, activeTaxZone);
+                await this.applyTaxes(ctx, order, activeTaxZone);
             }
             await this.applyShipping(ctx, order);
             await this.applyShippingPromotions(ctx, order, promotions);
@@ -88,10 +89,10 @@ export class OrderCalculator {
     /**
      * Applies the correct TaxRate to each OrderItem in the order.
      */
-    private applyTaxes(ctx: RequestContext, order: Order, activeZone: Zone) {
+    private async applyTaxes(ctx: RequestContext, order: Order, activeZone: Zone) {
         const getTaxRate = this.createTaxRateGetter(activeZone);
         for (const line of order.lines) {
-            this.applyTaxesToOrderLine(ctx, line, activeZone, getTaxRate);
+            await this.applyTaxesToOrderLine(ctx, order, line, activeZone, getTaxRate);
         }
         this.calculateOrderTotals(order);
     }
@@ -99,15 +100,23 @@ export class OrderCalculator {
     /**
      * Applies the correct TaxRate to an OrderLine
      */
-    private applyTaxesToOrderLine(
+    private async applyTaxesToOrderLine(
         ctx: RequestContext,
+        order: Order,
         line: OrderLine,
         activeZone: Zone,
         getTaxRate: (taxCategory: TaxCategory) => TaxRate,
     ) {
         const applicableTaxRate = getTaxRate(line.taxCategory);
+        const { taxLineCalculationStrategy } = this.configService.taxOptions;
         for (const item of line.activeItems) {
-            item.taxLines = [applicableTaxRate.apply(item.proratedUnitPrice)];
+            item.taxLines = await taxLineCalculationStrategy.calculate({
+                ctx,
+                applicableTaxRate,
+                order,
+                orderItem: item,
+                orderLine: line,
+            });
         }
     }
 

+ 34 - 0
packages/core/src/testing/order-test-utils.ts

@@ -1,4 +1,5 @@
 import { LanguageCode } from '@vendure/common/lib/generated-types';
+import { Omit } from '@vendure/common/lib/omit';
 import { ID } from '@vendure/common/lib/shared-types';
 
 import { RequestContext } from '../api/common/request-context';
@@ -124,3 +125,36 @@ export class MockTaxRateService {
         return rate || taxRateDefaultStandard;
     }
 }
+
+export function createOrder(
+    orderConfig: Partial<Omit<Order, 'lines'>> & {
+        ctx: RequestContext;
+        lines: Array<{
+            listPrice: number;
+            taxCategory: TaxCategory;
+            quantity: number;
+        }>;
+    },
+): Order {
+    const lines = orderConfig.lines.map(
+        ({ listPrice, taxCategory, quantity }) =>
+            new OrderLine({
+                taxCategory,
+                items: Array.from({ length: quantity }).map(
+                    () =>
+                        new OrderItem({
+                            listPrice,
+                            listPriceIncludesTax: orderConfig.ctx.channel.pricesIncludeTax,
+                            taxLines: [],
+                            adjustments: [],
+                        }),
+                ),
+            }),
+    );
+
+    return new Order({
+        couponCodes: [],
+        lines,
+        shippingLines: [],
+    });
+}

+ 3 - 1
packages/elasticsearch-plugin/e2e/graphql/generated-e2e-elasticsearch-plugin-types.ts

@@ -1553,7 +1553,7 @@ export type ProductVariant = Node & {
     assets: Array<Asset>;
     price: Scalars['Int'];
     currencyCode: CurrencyCode;
-    /** @deprecated price is now always exluding tax */
+    /** @deprecated price now always excludes tax */
     priceIncludesTax: Scalars['Boolean'];
     priceWithTax: Scalars['Int'];
     taxRateApplied: TaxRate;
@@ -3291,6 +3291,8 @@ export enum LanguageCode {
  * by taxRate.
  */
 export type OrderTaxSummary = {
+    /** A description of this tax */
+    description: Scalars['String'];
     /** The taxRate as a percentage */
     taxRate: Scalars['Float'];
     /** The total net price or OrderItems to which this taxRate applies */

Разница между файлами не показана из-за своего большого размера
+ 0 - 0
schema-admin.json


Разница между файлами не показана из-за своего большого размера
+ 0 - 0
schema-shop.json


+ 1 - 1
scripts/codegen/generate-graphql-types.ts

@@ -16,7 +16,7 @@ const specFileToIgnore = [
     'plugin.e2e-spec',
     'shop-definitions',
     'custom-fields.e2e-spec',
-    'price-calculation-strategy.e2e-spec',
+    'order-item-price-calculation-strategy.e2e-spec',
     'list-query-builder.e2e-spec',
     'shop-order.e2e-spec',
     'database-transactions.e2e-spec',

Некоторые файлы не были показаны из-за большого количества измененных файлов