소스 검색

refactor(server): Move price calculation logic to the OrderItem level

The OrderItem represents the true basic units of an order, whereas an OrderLine is more of an aggregation container for OrderItems. This means it makes sense to handle most of the logic at the OrderItem level.
Michael Bromley 7 년 전
부모
커밋
3a76c91eb5

+ 77 - 1
server/src/entity/order-item/order-item.entity.ts

@@ -1,7 +1,8 @@
-import { Adjustment } from 'shared/generated-types';
+import { Adjustment, AdjustmentType } from 'shared/generated-types';
 import { DeepPartial } from 'shared/shared-types';
 import { Column, Entity, ManyToOne } from 'typeorm';
 
+import { Calculated } from '../../common/calculated-decorator';
 import { VendureEntity } from '../base/base.entity';
 import { OrderLine } from '../order-line/order-line.entity';
 
@@ -14,5 +15,80 @@ export class OrderItem extends VendureEntity {
     @ManyToOne(type => OrderLine, line => line.items, { onDelete: 'CASCADE' })
     line: OrderLine;
 
+    @Column() readonly unitPrice: number;
+
+    @Column() unitPriceIncludesTax: boolean;
+
+    @Column() taxRate: number;
+
     @Column('simple-json') pendingAdjustments: Adjustment[];
+
+    @Calculated()
+    get unitPriceWithTax(): number {
+        if (this.unitPriceIncludesTax) {
+            return this.unitPrice;
+        } else {
+            return Math.round(this.unitPrice * ((100 + this.taxRate) / 100));
+        }
+    }
+
+    @Calculated()
+    get adjustments(): Adjustment[] {
+        if (this.unitPriceIncludesTax) {
+            return this.pendingAdjustments;
+        } else {
+            return this.pendingAdjustments.map(a => {
+                if (a.type === AdjustmentType.PROMOTION) {
+                    // Add the tax that would have been payable on the discount so that the numbers add up
+                    // for the end-user.
+                    const adjustmentWithTax = Math.round(a.amount * ((100 + this.taxRate) / 100));
+                    return {
+                        ...a,
+                        amount: adjustmentWithTax,
+                    };
+                }
+                return a;
+            });
+        }
+    }
+
+    /**
+     * This is the actual, final price of the OrderItem payable by the customer.
+     */
+    get unitPriceWithPromotionsAndTax(): number {
+        if (this.unitPriceIncludesTax) {
+            return this.unitPriceWithPromotions;
+        } else {
+            return this.unitPriceWithPromotions + this.unitTax;
+        }
+    }
+
+    get unitTax(): number {
+        if (this.unitPriceIncludesTax) {
+            return Math.round(
+                this.unitPriceWithPromotions - this.unitPriceWithPromotions / ((100 + this.taxRate) / 100),
+            );
+        } else {
+            const taxAdjustment = this.adjustments.find(a => a.type === AdjustmentType.TAX);
+            return taxAdjustment ? taxAdjustment.amount : 0;
+        }
+    }
+
+    get promotionAdjustmentsTotal(): number {
+        return this.pendingAdjustments
+            .filter(a => a.type === AdjustmentType.PROMOTION)
+            .reduce((total, a) => total + a.amount, 0);
+    }
+
+    get unitPriceWithPromotions(): number {
+        return this.unitPrice + this.promotionAdjustmentsTotal;
+    }
+
+    clearAdjustments(type?: AdjustmentType) {
+        if (!type) {
+            this.pendingAdjustments = [];
+        } else {
+            this.pendingAdjustments = this.pendingAdjustments.filter(a => a.type !== type);
+        }
+    }
 }

+ 5 - 0
server/src/entity/order-item/order-item.graphql

@@ -2,4 +2,9 @@ type OrderItem implements Node {
     id: ID!
     createdAt: DateTime!
     updatedAt: DateTime!
+    unitPrice: Int!
+    unitPriceWithTax: Int!
+    unitPriceIncludesTax: Boolean!
+    taxRate: Float!
+    adjustments: [Adjustment!]!
 }

+ 29 - 35
server/src/entity/order-line/order-line.entity.ts

@@ -25,12 +25,6 @@ export class OrderLine extends VendureEntity {
     @ManyToOne(type => Asset)
     featuredAsset: Asset;
 
-    @Column() unitPrice: number;
-
-    @Column() unitPriceIncludesTax: boolean;
-
-    @Column() includedTaxRate: number;
-
     @OneToMany(type => OrderItem, item => item.line)
     items: OrderItem[];
 
@@ -38,20 +32,13 @@ export class OrderLine extends VendureEntity {
     order: Order;
 
     @Calculated()
-    get unitPriceWithPromotions(): number {
-        const firstItemPromotionTotal = this.items[0].pendingAdjustments
-            .filter(a => a.type === AdjustmentType.PROMOTION)
-            .reduce((total, a) => total + a.amount, 0);
-        return this.unitPrice + firstItemPromotionTotal;
+    get unitPrice(): number {
+        return this.items ? this.items[0].unitPrice : 0;
     }
 
     @Calculated()
     get unitPriceWithTax(): number {
-        if (this.unitPriceIncludesTax) {
-            return this.unitPriceWithPromotions;
-        } else {
-            return this.unitPriceWithPromotions + this.unitTax;
-        }
+        return this.items ? this.items[0].unitPriceWithTax : 0;
     }
 
     @Calculated()
@@ -61,27 +48,40 @@ export class OrderLine extends VendureEntity {
 
     @Calculated()
     get totalPrice(): number {
-        return this.unitPriceWithTax * this.quantity;
+        return this.items.reduce((total, item) => total + item.unitPriceWithPromotionsAndTax, 0);
     }
 
     @Calculated()
     get adjustments(): Adjustment[] {
         if (this.items) {
-            return this.items[0].pendingAdjustments;
+            return this.items.reduce(
+                (adjustments, item) => [...adjustments, ...item.adjustments],
+                [] as Adjustment[],
+            );
         }
         return [];
     }
 
-    get unitTax(): number {
-        if (this.unitPriceIncludesTax) {
-            return Math.round(
-                this.unitPriceWithPromotions -
-                    this.unitPriceWithPromotions / ((100 + this.includedTaxRate) / 100),
-            );
-        } else {
-            const taxAdjustment = this.adjustments.find(a => a.type === AdjustmentType.TAX);
-            return taxAdjustment ? taxAdjustment.amount : 0;
-        }
+    get lineTax(): number {
+        return this.items.reduce((total, item) => total + item.unitTax, 0);
+    }
+
+    /**
+     * Sets whether the unitPrice of each OrderItem in the line includes tax.
+     */
+    setUnitPriceIncludesTax(includesTax: boolean) {
+        this.items.forEach(item => {
+            item.unitPriceIncludesTax = includesTax;
+        });
+    }
+
+    /**
+     * Sets the tax rate being applied to each Orderitem in this line.
+     */
+    setTaxRate(taxRate: number) {
+        this.items.forEach(item => {
+            item.taxRate = taxRate;
+        });
     }
 
     /**
@@ -89,12 +89,6 @@ export class OrderLine extends VendureEntity {
      * is specified, then all adjustments are removed.
      */
     clearAdjustments(type?: AdjustmentType) {
-        this.items.forEach(item => {
-            if (!type) {
-                item.pendingAdjustments = [];
-            } else {
-                item.pendingAdjustments = item.pendingAdjustments.filter(a => a.type !== type);
-            }
-        });
+        this.items.forEach(item => item.clearAdjustments(type));
     }
 }

+ 0 - 1
server/src/entity/order-line/order-line.graphql

@@ -5,7 +5,6 @@ type OrderLine implements Node {
     productVariant: ProductVariant!
     featuredAsset: Asset
     unitPrice: Int!
-    unitPriceWithPromotions: Int!
     unitPriceWithTax: Int!
     quantity: Int!
     items: [OrderItem!]!

+ 25 - 15
server/src/entity/subscribers.ts

@@ -1,4 +1,4 @@
-import { EntitySubscriberInterface, EventSubscriber } from 'typeorm';
+import { EntitySubscriberInterface, EventSubscriber, InsertEvent } from 'typeorm';
 
 import { CALCULATED_PROPERTIES } from '../common/calculated-decorator';
 
@@ -6,25 +6,35 @@ import { ProductVariantSubscriber } from './product-variant/product-variant.subs
 
 @EventSubscriber()
 export class CalculatedPropertySubscriber implements EntitySubscriberInterface {
+    afterLoad(event: any) {
+        this.moveCalculatedGettersToInstance(event);
+    }
+
+    afterInsert(event: InsertEvent<any>): Promise<any> | void {
+        this.moveCalculatedGettersToInstance(event.entity);
+    }
+
     /**
      * For any entity properties decorated with @Calculated(), this subscriber transfers
      * the getter from the entity prototype to the entity instance, so that it can be
      * correctly enumerated and serialized in the API response.
      */
-    afterLoad(event: any) {
-        const prototype = Object.getPrototypeOf(event);
-        if (prototype.hasOwnProperty(CALCULATED_PROPERTIES)) {
-            for (const property of prototype[CALCULATED_PROPERTIES]) {
-                const getterDescriptor = Object.getOwnPropertyDescriptor(prototype, property);
-                const getFn = getterDescriptor && getterDescriptor.get;
-                if (getFn) {
-                    const boundGetFn = getFn.bind(event);
-                    Object.defineProperties(event, {
-                        [property]: {
-                            get: () => boundGetFn(),
-                            enumerable: true,
-                        },
-                    });
+    private moveCalculatedGettersToInstance(entity: any) {
+        if (entity) {
+            const prototype = Object.getPrototypeOf(entity);
+            if (prototype.hasOwnProperty(CALCULATED_PROPERTIES)) {
+                for (const property of prototype[CALCULATED_PROPERTIES]) {
+                    const getterDescriptor = Object.getOwnPropertyDescriptor(prototype, property);
+                    const getFn = getterDescriptor && getterDescriptor.get;
+                    if (getFn) {
+                        const boundGetFn = getFn.bind(entity);
+                        Object.defineProperties(entity, {
+                            [property]: {
+                                get: () => boundGetFn(),
+                                enumerable: true,
+                            },
+                        });
+                    }
                 }
             }
         }

+ 6 - 2
server/src/service/helpers/order-calculator/order-calculator.spec.ts

@@ -45,9 +45,13 @@ describe('OrderCalculator', () => {
         const lines = orderConfig.lines.map(
             ({ unitPrice, taxCategory, quantity }) =>
                 new OrderLine({
-                    unitPrice,
                     taxCategory,
-                    items: Array.from({ length: quantity }).map(() => new OrderItem()),
+                    items: Array.from({ length: quantity }).map(
+                        () =>
+                            new OrderItem({
+                                unitPrice,
+                            }),
+                    ),
                 }),
         );
 

+ 4 - 4
server/src/service/helpers/order-calculator/order-calculator.ts

@@ -47,13 +47,13 @@ export class OrderCalculator {
                 ctx,
             );
 
-            line.unitPriceIncludesTax = priceIncludesTax;
-            line.includedTaxRate = applicableTaxRate.value;
+            line.setUnitPriceIncludesTax(priceIncludesTax);
+            line.setTaxRate(applicableTaxRate.value);
 
             if (!priceIncludesTax) {
                 for (const item of line.items) {
                     item.pendingAdjustments = item.pendingAdjustments.concat(
-                        applicableTaxRate.apply(line.unitPriceWithPromotions),
+                        applicableTaxRate.apply(item.unitPriceWithPromotions),
                     );
                 }
             }
@@ -90,7 +90,7 @@ export class OrderCalculator {
 
         for (const line of order.lines) {
             totalPrice += line.totalPrice;
-            totalTax += line.unitTax * line.quantity;
+            totalTax += line.lineTax;
         }
         const totalPriceBeforeTax = totalPrice - totalTax;
 

+ 14 - 6
server/src/service/services/order.service.ts

@@ -43,14 +43,18 @@ export class OrderService {
             relations: [
                 'lines',
                 'lines.productVariant',
+                'lines.productVariant.taxCategory',
                 'lines.featuredAsset',
                 'lines.items',
                 'lines.taxCategory',
             ],
         });
         if (order) {
-            order.lines.forEach(item => {
-                item.productVariant = translateDeep(item.productVariant, ctx.languageCode);
+            order.lines.forEach(line => {
+                line.productVariant = translateDeep(
+                    this.productVariantService.applyChannelPriceAndTax(line.productVariant, ctx),
+                    ctx.languageCode,
+                );
             });
             return order;
         }
@@ -100,10 +104,14 @@ export class OrderService {
             if (!orderLine.items) {
                 orderLine.items = [];
             }
+            const productVariant = orderLine.productVariant;
             for (let i = currentQuantity; i < quantity; i++) {
                 const orderItem = await this.connection.getRepository(OrderItem).save(
                     new OrderItem({
+                        unitPrice: productVariant.price,
                         pendingAdjustments: [],
+                        unitPriceIncludesTax: productVariant.priceIncludesTax,
+                        taxRate: productVariant.priceIncludesTax ? productVariant.taxRateApplied.value : 0,
                     }),
                 );
                 orderLine.items.push(orderItem);
@@ -159,9 +167,6 @@ export class OrderService {
             productVariant,
             taxCategory: productVariant.taxCategory,
             featuredAsset: productVariant.product.featuredAsset,
-            unitPrice: productVariant.price,
-            unitPriceIncludesTax: productVariant.priceIncludesTax,
-            includedTaxRate: productVariant.priceIncludesTax ? productVariant.taxRateApplied.value : 0,
         });
     }
 
@@ -175,7 +180,10 @@ export class OrderService {
     }
 
     private async applyTaxesAndPromotions(ctx: RequestContext, order: Order): Promise<Order> {
-        const promotions = await this.connection.getRepository(Promotion).find({ where: { enabled: true } });
+        const promotions = await this.connection.getRepository(Promotion).find({
+            where: { enabled: true },
+            order: { priorityScore: 'ASC' },
+        });
         order = this.orderCalculator.applyTaxesAndPromotions(ctx, order, promotions);
         await this.connection.getRepository(Order).save(order);
         await this.connection.getRepository(OrderItem).save(order.getOrderItems());