Quellcode durchsuchen

feat(server): Add tax adjustments to orders

Michael Bromley vor 7 Jahren
Ursprung
Commit
460978542e

Datei-Diff unterdrückt, da er zu groß ist
+ 0 - 0
schema.json


+ 11 - 12
server/src/common/types/adjustment-source.ts

@@ -1,16 +1,15 @@
+import { Adjustment, AdjustmentType } from 'shared/generated-types';
 import { ID } from 'shared/shared-types';
 
-export interface AdjustmentSource {
-    test(): boolean;
-    apply(): Adjustment[];
-}
+import { VendureEntity } from '../../entity/base/base.entity';
+
+export abstract class AdjustmentSource extends VendureEntity {
+    type: AdjustmentType;
+
+    getSourceId(): string {
+        return `${this.type}:${this.id}`;
+    }
 
-/**
- * When an AdjustmentSource is applied to an OrderItem, an Adjustment is
- * generated based on the actions assigned to the AdjustmentSource.
- */
-export interface Adjustment {
-    adjustmentSourceId: ID;
-    description: string;
-    amount: number;
+    abstract test(...args: any[]): boolean;
+    abstract apply(...args: any[]): Adjustment;
 }

+ 10 - 1
server/src/common/types/common-types.graphql

@@ -3,8 +3,17 @@ scalar JSON
 scalar DateTime
 scalar Upload
 
+enum AdjustmentType {
+    TAX
+    PROMOTION
+    REFUND
+    TAX_REFUND
+    PROMOTION_REFUND
+}
+
 type Adjustment {
-    promotionId: ID!
+    adjustmentSource: String!
+    type: AdjustmentType!
     description: String!
     amount: Int!
 }

+ 2 - 2
server/src/entity/order-item/order-item.entity.ts

@@ -1,7 +1,7 @@
+import { Adjustment } from 'shared/generated-types';
 import { DeepPartial } from 'shared/shared-types';
 import { Column, Entity, ManyToOne } from 'typeorm';
 
-import { Adjustment } from '../../common/types/adjustment-source';
 import { VendureEntity } from '../base/base.entity';
 import { OrderLine } from '../order-line/order-line.entity';
 
@@ -11,7 +11,7 @@ export class OrderItem extends VendureEntity {
         super(input);
     }
 
-    @ManyToOne(type => OrderLine, line => line.items)
+    @ManyToOne(type => OrderLine, line => line.items, { onDelete: 'CASCADE' })
     line: OrderLine;
 
     @Column('simple-json') pendingAdjustments: Adjustment[];

+ 26 - 4
server/src/entity/order-line/order-line.entity.ts

@@ -1,13 +1,14 @@
-import { DeepPartial, ID } from 'shared/shared-types';
+import { Adjustment, AdjustmentType } from 'shared/generated-types';
+import { DeepPartial } from 'shared/shared-types';
 import { Column, Entity, ManyToOne, OneToMany } from 'typeorm';
 
 import { Calculated } from '../../common/calculated-decorator';
-import { Adjustment } from '../../common/types/adjustment-source';
 import { Asset } from '../asset/asset.entity';
 import { VendureEntity } from '../base/base.entity';
 import { OrderItem } from '../order-item/order-item.entity';
 import { Order } from '../order/order.entity';
 import { ProductVariant } from '../product-variant/product-variant.entity';
+import { TaxCategory } from '../tax-category/tax-category.entity';
 
 @Entity()
 export class OrderLine extends VendureEntity {
@@ -18,7 +19,8 @@ export class OrderLine extends VendureEntity {
     @ManyToOne(type => ProductVariant)
     productVariant: ProductVariant;
 
-    @Column('varchar') taxCategoryId: ID;
+    @ManyToOne(type => TaxCategory)
+    taxCategory: TaxCategory;
 
     @ManyToOne(type => Asset)
     featuredAsset: Asset;
@@ -31,6 +33,12 @@ export class OrderLine extends VendureEntity {
     @ManyToOne(type => Order, order => order.lines)
     order: Order;
 
+    @Calculated()
+    get unitPriceWithTax(): number {
+        const taxAdjustment = this.adjustments.find(a => a.type === AdjustmentType.TAX);
+        return this.unitPrice + (taxAdjustment ? taxAdjustment.amount : 0);
+    }
+
     @Calculated()
     get quantity(): number {
         return this.items ? this.items.length : 0;
@@ -38,6 +46,20 @@ export class OrderLine extends VendureEntity {
 
     @Calculated()
     get totalPrice(): number {
-        return this.unitPrice * this.quantity;
+        const taxAdjustments = this.adjustments
+            .filter(a => a.type === AdjustmentType.TAX)
+            .reduce((amount, a) => amount + a.amount, 0);
+        return this.unitPrice * this.quantity + taxAdjustments;
+    }
+
+    @Calculated()
+    get adjustments(): Adjustment[] {
+        if (this.items) {
+            return this.items.reduce(
+                (adjustments, i) => [...adjustments, ...i.pendingAdjustments],
+                [] as Adjustment[],
+            );
+        }
+        return [];
     }
 }

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

@@ -5,8 +5,10 @@ type OrderLine implements Node {
     productVariant: ProductVariant!
     featuredAsset: Asset
     unitPrice: Int!
+    unitPriceWithTax: Int!
     quantity: Int!
     items: [OrderItem!]!
     totalPrice: Int!
+    adjustments: [Adjustment!]!
     order: Order!
 }

+ 2 - 5
server/src/entity/order/order.entity.ts

@@ -1,7 +1,6 @@
 import { DeepPartial } from 'shared/shared-types';
 import { Column, Entity, ManyToOne, OneToMany } from 'typeorm';
 
-import { Adjustment } from '../../common/types/adjustment-source';
 import { VendureEntity } from '../base/base.entity';
 import { Customer } from '../customer/customer.entity';
 import { OrderLine } from '../order-line/order-line.entity';
@@ -20,9 +19,7 @@ export class Order extends VendureEntity {
     @OneToMany(type => OrderLine, line => line.order)
     lines: OrderLine[];
 
-    @Column() totalPrice: number;
+    @Column() totalPriceBeforeTax: number;
 
-    get adjustments() {
-        return [];
-    }
+    @Column() totalPrice: number;
 }

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

@@ -5,6 +5,6 @@ type Order implements Node {
     code: String!
     customer: Customer
     lines: [OrderLine!]!
-    adjustments: [Adjustment!]
+    totalPriceBeforeTax: Int!
     totalPrice: Int!
 }

+ 7 - 5
server/src/entity/promotion/promotion.entity.ts

@@ -1,13 +1,15 @@
-import { AdjustmentOperation, AdjustmentType } from 'shared/generated-types';
+import { Adjustment, AdjustmentOperation, AdjustmentType } from 'shared/generated-types';
 import { DeepPartial } from 'shared/shared-types';
 import { Column, Entity, JoinTable, ManyToMany } from 'typeorm';
 
-import { Adjustment, AdjustmentSource } from '../../common/types/adjustment-source';
+import { AdjustmentSource } from '../../common/types/adjustment-source';
 import { VendureEntity } from '../base/base.entity';
 import { Channel } from '../channel/channel.entity';
 
 @Entity()
-export class Promotion extends VendureEntity implements AdjustmentSource {
+export class Promotion extends AdjustmentSource {
+    type = AdjustmentType.PROMOTION;
+
     constructor(input?: DeepPartial<Promotion>) {
         super(input);
     }
@@ -24,8 +26,8 @@ export class Promotion extends VendureEntity implements AdjustmentSource {
 
     @Column('simple-json') actions: AdjustmentOperation[];
 
-    apply(): Adjustment[] {
-        return [];
+    apply(): Adjustment {
+        return {} as any;
     }
 
     test(): boolean {

+ 17 - 10
server/src/entity/tax-rate/tax-rate.entity.ts

@@ -1,16 +1,17 @@
-import { AdjustmentOperation } from 'shared/generated-types';
+import { Adjustment, AdjustmentType } from 'shared/generated-types';
 import { DeepPartial } from 'shared/shared-types';
-import { Column, Entity, JoinTable, ManyToMany, ManyToOne } from 'typeorm';
+import { Column, Entity, ManyToOne } from 'typeorm';
 
-import { Adjustment, AdjustmentSource } from '../../common/types/adjustment-source';
-import { VendureEntity } from '../base/base.entity';
-import { Channel } from '../channel/channel.entity';
+import { AdjustmentSource } from '../../common/types/adjustment-source';
+import { idsAreEqual } from '../../common/utils';
 import { CustomerGroup } from '../customer-group/customer-group.entity';
 import { TaxCategory } from '../tax-category/tax-category.entity';
 import { Zone } from '../zone/zone.entity';
 
 @Entity()
-export class TaxRate extends VendureEntity implements AdjustmentSource {
+export class TaxRate extends AdjustmentSource {
+    readonly type = AdjustmentType.TAX;
+
     constructor(input?: DeepPartial<TaxRate>) {
         super(input);
     }
@@ -30,11 +31,17 @@ export class TaxRate extends VendureEntity implements AdjustmentSource {
     @ManyToOne(type => CustomerGroup, { nullable: true })
     customerGroup?: CustomerGroup;
 
-    apply(): Adjustment[] {
-        return [];
+    apply(price: number): Adjustment {
+        const tax = Math.round(price * (this.value / 100));
+        return {
+            type: this.type,
+            adjustmentSource: this.getSourceId(),
+            description: this.name,
+            amount: tax,
+        };
     }
 
-    test(): boolean {
-        return false;
+    test(zone: Zone, taxCategory: TaxCategory): boolean {
+        return idsAreEqual(taxCategory.id, this.category.id) && idsAreEqual(zone.id, this.zone.id);
     }
 }

+ 63 - 10
server/src/service/providers/order.service.ts

@@ -1,4 +1,5 @@
 import { InjectConnection } from '@nestjs/typeorm';
+import { Adjustment, AdjustmentType } from 'shared/generated-types';
 import { ID, PaginatedList } from 'shared/shared-types';
 import { Connection } from 'typeorm';
 
@@ -10,6 +11,8 @@ 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 { ProductVariant } from '../../entity/product-variant/product-variant.entity';
+import { Promotion } from '../../entity/promotion/promotion.entity';
+import { TaxRate } from '../../entity/tax-rate/tax-rate.entity';
 import { I18nError } from '../../i18n/i18n-error';
 import { buildListQuery } from '../helpers/build-list-query';
 import { translateDeep } from '../helpers/translate-entity';
@@ -37,7 +40,13 @@ export class OrderService {
 
     async findOne(ctx: RequestContext, orderId: ID): Promise<Order | undefined> {
         const order = await this.connection.getRepository(Order).findOne(orderId, {
-            relations: ['lines', 'lines.productVariant', 'lines.featuredAsset', 'lines.items'],
+            relations: [
+                'lines',
+                'lines.productVariant',
+                'lines.featuredAsset',
+                'lines.items',
+                'lines.taxCategory',
+            ],
         });
         if (order) {
             order.lines.forEach(item => {
@@ -70,7 +79,7 @@ export class OrderService {
         if (!orderLine) {
             const newLine = new OrderLine({
                 productVariant,
-                taxCategoryId: productVariant.taxCategory.id,
+                taxCategory: productVariant.taxCategory,
                 featuredAsset: productVariant.product.featuredAsset,
                 unitPrice: productVariant.price,
             });
@@ -107,14 +116,16 @@ export class OrderService {
             orderLine.items = orderLine.items.slice(0, quantity);
         }
         await this.connection.getRepository(OrderLine).save(orderLine);
-        return assertFound(this.findOne(ctx, order.id));
+        return this.calculateOrderTotals(ctx, order);
     }
 
-    async removeItemFromOrder(ctx: RequestContext, orderId: ID, orderItemId: ID): Promise<Order> {
+    async removeItemFromOrder(ctx: RequestContext, orderId: ID, orderLineId: ID): Promise<Order> {
         const order = await this.getOrderOrThrow(ctx, orderId);
-        const orderItem = this.getOrderLineOrThrow(order, orderItemId);
-        order.lines = order.lines.filter(item => !idsAreEqual(item.id, orderItemId));
-        return assertFound(this.findOne(ctx, order.id));
+        const orderLine = this.getOrderLineOrThrow(order, orderLineId);
+        order.lines = order.lines.filter(line => !idsAreEqual(line.id, orderLineId));
+        const updatedOrder = await this.calculateOrderTotals(ctx, order);
+        await this.connection.getRepository(OrderLine).remove(orderLine);
+        return updatedOrder;
     }
 
     private async getOrderOrThrow(ctx: RequestContext, orderId: ID): Promise<Order> {
@@ -139,10 +150,10 @@ export class OrderService {
         return productVariant;
     }
 
-    private getOrderLineOrThrow(order: Order, orderItemId: ID): OrderLine {
-        const orderItem = order.lines.find(item => idsAreEqual(item.id, orderItemId));
+    private getOrderLineOrThrow(order: Order, orderLineId: ID): OrderLine {
+        const orderItem = order.lines.find(line => idsAreEqual(line.id, orderLineId));
         if (!orderItem) {
-            throw new I18nError(`error.order-does-not-contain-item-with-id`, { id: orderItemId });
+            throw new I18nError(`error.order-does-not-contain-line-with-id`, { id: orderLineId });
         }
         return orderItem;
     }
@@ -155,4 +166,46 @@ export class OrderService {
             throw new I18nError(`error.order-item-quantity-must-be-positive`, { quantity });
         }
     }
+
+    private async calculateOrderTotals(ctx: RequestContext, order: Order): Promise<Order> {
+        if (!ctx.channel) {
+            throw new I18nError(`error.no-active-channel`);
+        }
+        const activeZone = ctx.channel.defaultTaxZone;
+
+        const taxRates = await this.connection.getRepository(TaxRate).find({
+            where: {
+                enabled: true,
+                zone: activeZone,
+            },
+            relations: ['category', 'zone', 'customerGroup'],
+        });
+        const promotions = await this.connection.getRepository(Promotion).find({ where: { enabled: true } });
+
+        for (const line of order.lines) {
+            const applicableTaxRate = taxRates.find(taxRate => taxRate.test(activeZone, line.taxCategory));
+
+            for (const item of line.items) {
+                if (applicableTaxRate) {
+                    item.pendingAdjustments = [];
+                    item.pendingAdjustments = item.pendingAdjustments.concat(
+                        applicableTaxRate.apply(line.unitPrice),
+                    );
+                    await this.connection.getRepository(OrderItem).save(item);
+                }
+            }
+        }
+
+        const totalPrice = order.lines.reduce((total, line) => total + line.totalPrice, 0);
+        const totalTax = order.lines
+            .reduce((adjustments, line) => [...adjustments, ...line.adjustments], [] as Adjustment[])
+            .filter(a => a.type === AdjustmentType.TAX)
+            .reduce((total, a) => total + a.amount, 0);
+        const totalPriceBeforeTax = totalPrice - totalTax;
+
+        order.totalPriceBeforeTax = totalPriceBeforeTax;
+        order.totalPrice = totalPrice;
+        await this.connection.getRepository(Order).save(order);
+        return order;
+    }
 }

+ 3 - 3
server/src/service/providers/tax-rate.service.ts

@@ -51,13 +51,13 @@ export class TaxRateService {
         }
         const updatedTaxRate = patchEntity(taxRate, input);
         if (input.categoryId) {
-            taxRate.category = await this.getTaxCategoryOrThrow(input.categoryId);
+            updatedTaxRate.category = await this.getTaxCategoryOrThrow(input.categoryId);
         }
         if (input.zoneId) {
-            taxRate.category = await this.getZoneOrThrow(input.zoneId);
+            updatedTaxRate.category = await this.getZoneOrThrow(input.zoneId);
         }
         if (input.customerGroupId) {
-            taxRate.customerGroup = await this.getCustomerGroupOrThrow(input.customerGroupId);
+            updatedTaxRate.customerGroup = await this.getCustomerGroupOrThrow(input.customerGroupId);
         }
         await this.connection.getRepository(TaxRate).save(updatedTaxRate);
         return assertFound(this.findOne(taxRate.id));

+ 30 - 11
shared/generated-types.ts

@@ -120,8 +120,8 @@ export interface Channel extends Node {
     updatedAt: DateTime;
     code: string;
     token: string;
-    defaultTaxZone: Zone;
-    defaultShippingZone: Zone;
+    defaultTaxZone?: Zone | null;
+    defaultShippingZone?: Zone | null;
     defaultLanguageCode: LanguageCode;
 }
 
@@ -347,7 +347,8 @@ export interface OrderItem extends Node {
 }
 
 export interface Adjustment {
-    promotionId: string;
+    adjustmentSource: string;
+    type: AdjustmentType;
     description: string;
     amount: number;
 }
@@ -1599,6 +1600,14 @@ export enum AssetType {
     BINARY = 'BINARY',
 }
 
+export enum AdjustmentType {
+    TAX = 'TAX',
+    PROMOTION = 'PROMOTION',
+    REFUND = 'REFUND',
+    TAX_REFUND = 'TAX_REFUND',
+    PROMOTION_REFUND = 'PROMOTION_REFUND',
+}
+
 export namespace QueryResolvers {
     export interface Resolvers<Context = any> {
         administrators?: AdministratorsResolver<AdministratorList, any, Context>;
@@ -2041,8 +2050,8 @@ export namespace ChannelResolvers {
         updatedAt?: UpdatedAtResolver<DateTime, any, Context>;
         code?: CodeResolver<string, any, Context>;
         token?: TokenResolver<string, any, Context>;
-        defaultTaxZone?: DefaultTaxZoneResolver<Zone, any, Context>;
-        defaultShippingZone?: DefaultShippingZoneResolver<Zone, any, Context>;
+        defaultTaxZone?: DefaultTaxZoneResolver<Zone | null, any, Context>;
+        defaultShippingZone?: DefaultShippingZoneResolver<Zone | null, any, Context>;
         defaultLanguageCode?: DefaultLanguageCodeResolver<LanguageCode, any, Context>;
     }
 
@@ -2051,8 +2060,12 @@ export namespace ChannelResolvers {
     export type UpdatedAtResolver<R = DateTime, Parent = any, Context = any> = Resolver<R, Parent, Context>;
     export type CodeResolver<R = string, Parent = any, Context = any> = Resolver<R, Parent, Context>;
     export type TokenResolver<R = string, Parent = any, Context = any> = Resolver<R, Parent, Context>;
-    export type DefaultTaxZoneResolver<R = Zone, Parent = any, Context = any> = Resolver<R, Parent, Context>;
-    export type DefaultShippingZoneResolver<R = Zone, Parent = any, Context = any> = Resolver<
+    export type DefaultTaxZoneResolver<R = Zone | null, Parent = any, Context = any> = Resolver<
+        R,
+        Parent,
+        Context
+    >;
+    export type DefaultShippingZoneResolver<R = Zone | null, Parent = any, Context = any> = Resolver<
         R,
         Parent,
         Context
@@ -2676,12 +2689,18 @@ export namespace OrderItemResolvers {
 
 export namespace AdjustmentResolvers {
     export interface Resolvers<Context = any> {
-        promotionId?: PromotionIdResolver<string, any, Context>;
+        adjustmentSource?: AdjustmentSourceResolver<string, any, Context>;
+        type?: TypeResolver<AdjustmentType, any, Context>;
         description?: DescriptionResolver<string, any, Context>;
         amount?: AmountResolver<number, any, Context>;
     }
 
-    export type PromotionIdResolver<R = string, Parent = any, Context = any> = Resolver<R, Parent, Context>;
+    export type AdjustmentSourceResolver<R = string, Parent = any, Context = any> = Resolver<
+        R,
+        Parent,
+        Context
+    >;
+    export type TypeResolver<R = AdjustmentType, Parent = any, Context = any> = Resolver<R, Parent, Context>;
     export type DescriptionResolver<R = string, Parent = any, Context = any> = Resolver<R, Parent, Context>;
     export type AmountResolver<R = number, Parent = any, Context = any> = Resolver<R, Parent, Context>;
 }
@@ -4947,8 +4966,8 @@ export namespace Channel {
         code: string;
         token: string;
         defaultLanguageCode: LanguageCode;
-        defaultShippingZone: DefaultShippingZone;
-        defaultTaxZone: DefaultTaxZone;
+        defaultShippingZone?: DefaultShippingZone | null;
+        defaultTaxZone?: DefaultTaxZone | null;
     };
 
     export type DefaultShippingZone = {

Einige Dateien werden nicht angezeigt, da zu viele Dateien in diesem Diff geändert wurden.