Browse Source

feat(server): Implement Order merging strategies on Customer login

Closes #34
Michael Bromley 7 years ago
parent
commit
55943fc4c2

+ 1 - 1
server/src/api/resolvers/auth.resolver.ts

@@ -52,7 +52,7 @@ export class AuthResolver {
         if (!token) {
             return false;
         }
-        await this.authService.invalidateSessionByToken(token);
+        await this.authService.deleteSessionByToken(token);
         setAuthToken({
             req,
             res,

+ 1 - 0
server/src/config/config.service.mock.ts

@@ -21,6 +21,7 @@ export class MockConfigService implements MockClass<ConfigService> {
     dbConnectionOptions = {};
     promotionConditions = [];
     promotionActions = [];
+    orderMergeOptions = {};
     orderProcessOptions = {};
     customFields = {};
     middleware = [];

+ 11 - 1
server/src/config/config.service.ts

@@ -13,7 +13,13 @@ import { AssetStorageStrategy } from './asset-storage-strategy/asset-storage-str
 import { EntityIdStrategy } from './entity-id-strategy/entity-id-strategy';
 import { PromotionAction } from './promotion/promotion-action';
 import { PromotionCondition } from './promotion/promotion-condition';
-import { AuthOptions, getConfig, OrderProcessOptions, VendureConfig } from './vendure-config';
+import {
+    AuthOptions,
+    getConfig,
+    OrderMergeOptions,
+    OrderProcessOptions,
+    VendureConfig,
+} from './vendure-config';
 import { VendurePlugin } from './vendure-plugin/vendure-plugin';
 
 @Injectable()
@@ -90,6 +96,10 @@ export class ConfigService implements VendureConfig {
         return this.activeConfig.promotionActions;
     }
 
+    get orderMergeOptions(): OrderMergeOptions {
+        return this.activeConfig.orderMergeOptions;
+    }
+
     get orderProcessOptions(): OrderProcessOptions<any> {
         return this.activeConfig.orderProcessOptions;
     }

+ 6 - 0
server/src/config/default-config.ts

@@ -8,6 +8,8 @@ import { DefaultAssetNamingStrategy } from './asset-naming-strategy/default-asse
 import { NoAssetPreviewStrategy } from './asset-preview-strategy/no-asset-preview-strategy';
 import { NoAssetStorageStrategy } from './asset-storage-strategy/no-asset-storage-strategy';
 import { AutoIncrementIdStrategy } from './entity-id-strategy/auto-increment-id-strategy';
+import { MergeOrdersStrategy } from './order-merge-strategy/merge-orders-strategy';
+import { UseGuestStrategy } from './order-merge-strategy/use-guest-strategy';
 import { defaultPromotionActions } from './promotion/default-promotion-actions';
 import { defaultPromotionConditions } from './promotion/default-promotion-conditions';
 import { VendureConfig } from './vendure-config';
@@ -43,6 +45,10 @@ export const defaultConfig: ReadOnlyRequired<VendureConfig> = {
     promotionConditions: defaultPromotionConditions,
     promotionActions: defaultPromotionActions,
     orderProcessOptions: {},
+    orderMergeOptions: {
+        mergeStrategy: new MergeOrdersStrategy(),
+        checkoutMergeStrategy: new UseGuestStrategy(),
+    },
     customFields: {
         Address: [],
         Customer: [],

+ 96 - 0
server/src/config/order-merge-strategy/merge-orders-strategy.spec.ts

@@ -0,0 +1,96 @@
+import { Order } from '../../entity/order/order.entity';
+import { createOrderFromLines, parseLines } from '../../testing/order-test-utils';
+
+import { MergeOrdersStrategy } from './merge-orders-strategy';
+
+describe('MergeOrdersStrategy', () => {
+    const strategy = new MergeOrdersStrategy();
+
+    it('both orders empty', () => {
+        const guestOrder = new Order({ lines: [] });
+        const existingOrder = new Order({ lines: [] });
+
+        const result = strategy.merge(guestOrder, existingOrder);
+
+        expect(result).toEqual([]);
+    });
+
+    it('existingOrder empty', () => {
+        const guestLines = [{ lineId: 1, quantity: 2, productVariantId: 100 }];
+        const guestOrder = createOrderFromLines(guestLines);
+        const existingOrder = new Order({ lines: [] });
+
+        const result = strategy.merge(guestOrder, existingOrder);
+
+        expect(parseLines(result)).toEqual(guestLines);
+    });
+
+    it('guestOrder empty', () => {
+        const existingLines = [{ lineId: 1, quantity: 2, productVariantId: 100 }];
+        const guestOrder = new Order({ lines: [] });
+        const existingOrder = createOrderFromLines(existingLines);
+
+        const result = strategy.merge(guestOrder, existingOrder);
+
+        expect(parseLines(result)).toEqual(existingLines);
+    });
+
+    it('both orders have non-conflicting lines', () => {
+        const guestLines = [
+            { lineId: 21, quantity: 2, productVariantId: 201 },
+            { lineId: 22, quantity: 1, productVariantId: 202 },
+        ];
+        const existingLines = [
+            { lineId: 1, quantity: 1, productVariantId: 101 },
+            { lineId: 2, quantity: 1, productVariantId: 102 },
+            { lineId: 3, quantity: 1, productVariantId: 103 },
+        ];
+        const guestOrder = createOrderFromLines(guestLines);
+        const existingOrder = createOrderFromLines(existingLines);
+
+        const result = strategy.merge(guestOrder, existingOrder);
+
+        expect(parseLines(result)).toEqual([
+            { lineId: 21, quantity: 2, productVariantId: 201 },
+            { lineId: 22, quantity: 1, productVariantId: 202 },
+            { lineId: 1, quantity: 1, productVariantId: 101 },
+            { lineId: 2, quantity: 1, productVariantId: 102 },
+            { lineId: 3, quantity: 1, productVariantId: 103 },
+        ]);
+    });
+
+    it('both orders have conflicting lines, some of which conflict', () => {
+        const guestLines = [
+            { lineId: 21, quantity: 2, productVariantId: 102 },
+            { lineId: 22, quantity: 1, productVariantId: 202 },
+        ];
+        const existingLines = [
+            { lineId: 1, quantity: 1, productVariantId: 101 },
+            { lineId: 2, quantity: 1, productVariantId: 102 },
+            { lineId: 3, quantity: 1, productVariantId: 103 },
+        ];
+        const guestOrder = createOrderFromLines(guestLines);
+        const existingOrder = createOrderFromLines(existingLines);
+
+        const result = strategy.merge(guestOrder, existingOrder);
+
+        expect(parseLines(result)).toEqual([
+            { lineId: 22, quantity: 1, productVariantId: 202 },
+            { lineId: 1, quantity: 1, productVariantId: 101 },
+            { lineId: 2, quantity: 1, productVariantId: 102 },
+            { lineId: 3, quantity: 1, productVariantId: 103 },
+        ]);
+    });
+
+    it('returns a new array', () => {
+        const guestLines = [{ lineId: 21, quantity: 2, productVariantId: 102 }];
+        const existingLines = [{ lineId: 1, quantity: 1, productVariantId: 101 }];
+        const guestOrder = createOrderFromLines(guestLines);
+        const existingOrder = createOrderFromLines(existingLines);
+
+        const result = strategy.merge(guestOrder, existingOrder);
+
+        expect(result).not.toBe(guestOrder.lines);
+        expect(result).not.toBe(existingOrder.lines);
+    });
+});

+ 26 - 0
server/src/config/order-merge-strategy/merge-orders-strategy.ts

@@ -0,0 +1,26 @@
+import { OrderLine } from '../../entity/order-line/order-line.entity';
+import { Order } from '../../entity/order/order.entity';
+
+import { OrderMergeStrategy } from './order-merge-strategy';
+
+/**
+ * Merges both Orders. If the guest order contains items which are already in the
+ * existing Order, the guest Order quantity will replace that of the existing Order.
+ */
+export class MergeOrdersStrategy implements OrderMergeStrategy {
+    merge(guestOrder: Order, existingOrder: Order): OrderLine[] {
+        const mergedLines = existingOrder.lines.slice();
+        const guestLines = guestOrder.lines.slice();
+        for (const guestLine of guestLines.reverse()) {
+            const existingLine = this.findCorrespondingLine(existingOrder, guestLine);
+            if (!existingLine) {
+                mergedLines.unshift(guestLine);
+            }
+        }
+        return mergedLines;
+    }
+
+    private findCorrespondingLine(existingOrder: Order, guestLine: OrderLine): OrderLine | undefined {
+        return existingOrder.lines.find(line => line.productVariant.id === guestLine.productVariant.id);
+    }
+}

+ 13 - 0
server/src/config/order-merge-strategy/order-merge-strategy.ts

@@ -0,0 +1,13 @@
+import { OrderLine } from '../../entity/order-line/order-line.entity';
+import { Order } from '../../entity/order/order.entity';
+
+/**
+ * An OrderMergeStrategy defines what happens when a Customer with an existing Order
+ * signs in with a guest Order, where both Orders may contain differing OrderLines.
+ *
+ * Somehow these differing OrderLines need to be reconciled into a single collection
+ * of OrderLines. The OrderMergeStrategy defines the rules governing this reconciliation.
+ */
+export interface OrderMergeStrategy {
+    merge(guestOrder: Order, existingOrder: Order): OrderLine[];
+}

+ 85 - 0
server/src/config/order-merge-strategy/use-existing-strategy.spec.ts

@@ -0,0 +1,85 @@
+import { Order } from '../../entity/order/order.entity';
+import { createOrderFromLines, parseLines } from '../../testing/order-test-utils';
+
+import { UseExistingStrategy } from './use-existing-strategy';
+
+describe('UseExistingStrategy', () => {
+    const strategy = new UseExistingStrategy();
+
+    it('both orders empty', () => {
+        const guestOrder = new Order({ lines: [] });
+        const existingOrder = new Order({ lines: [] });
+
+        const result = strategy.merge(guestOrder, existingOrder);
+
+        expect(result).toEqual([]);
+    });
+
+    it('existingOrder empty', () => {
+        const guestLines = [{ lineId: 1, quantity: 2, productVariantId: 100 }];
+        const guestOrder = createOrderFromLines(guestLines);
+        const existingOrder = new Order({ lines: [] });
+
+        const result = strategy.merge(guestOrder, existingOrder);
+
+        expect(parseLines(result)).toEqual([]);
+    });
+
+    it('guestOrder empty', () => {
+        const existingLines = [{ lineId: 1, quantity: 2, productVariantId: 100 }];
+        const guestOrder = new Order({ lines: [] });
+        const existingOrder = createOrderFromLines(existingLines);
+
+        const result = strategy.merge(guestOrder, existingOrder);
+
+        expect(parseLines(result)).toEqual(existingLines);
+    });
+
+    it('both orders have non-conflicting lines', () => {
+        const guestLines = [
+            { lineId: 21, quantity: 2, productVariantId: 201 },
+            { lineId: 22, quantity: 1, productVariantId: 202 },
+        ];
+        const existingLines = [
+            { lineId: 1, quantity: 1, productVariantId: 101 },
+            { lineId: 2, quantity: 1, productVariantId: 102 },
+            { lineId: 3, quantity: 1, productVariantId: 103 },
+        ];
+        const guestOrder = createOrderFromLines(guestLines);
+        const existingOrder = createOrderFromLines(existingLines);
+
+        const result = strategy.merge(guestOrder, existingOrder);
+
+        expect(parseLines(result)).toEqual(existingLines);
+    });
+
+    it('both orders have conflicting lines, some of which conflict', () => {
+        const guestLines = [
+            { lineId: 21, quantity: 2, productVariantId: 102 },
+            { lineId: 22, quantity: 1, productVariantId: 202 },
+        ];
+        const existingLines = [
+            { lineId: 1, quantity: 1, productVariantId: 101 },
+            { lineId: 2, quantity: 1, productVariantId: 102 },
+            { lineId: 3, quantity: 1, productVariantId: 103 },
+        ];
+        const guestOrder = createOrderFromLines(guestLines);
+        const existingOrder = createOrderFromLines(existingLines);
+
+        const result = strategy.merge(guestOrder, existingOrder);
+
+        expect(parseLines(result)).toEqual(existingLines);
+    });
+
+    it('returns a new array', () => {
+        const guestLines = [{ lineId: 21, quantity: 2, productVariantId: 102 }];
+        const existingLines = [{ lineId: 1, quantity: 1, productVariantId: 101 }];
+        const guestOrder = createOrderFromLines(guestLines);
+        const existingOrder = createOrderFromLines(existingLines);
+
+        const result = strategy.merge(guestOrder, existingOrder);
+
+        expect(result).not.toBe(guestOrder.lines);
+        expect(result).not.toBe(existingOrder.lines);
+    });
+});

+ 10 - 0
server/src/config/order-merge-strategy/use-existing-strategy.ts

@@ -0,0 +1,10 @@
+import { OrderLine } from '../../entity/order-line/order-line.entity';
+import { Order } from '../../entity/order/order.entity';
+
+import { OrderMergeStrategy } from './order-merge-strategy';
+
+export class UseExistingStrategy implements OrderMergeStrategy {
+    merge(guestOrder: Order, existingOrder: Order): OrderLine[] {
+        return existingOrder.lines.slice();
+    }
+}

+ 85 - 0
server/src/config/order-merge-strategy/use-guest-if-existing-empty-strategy.spec.ts

@@ -0,0 +1,85 @@
+import { Order } from '../../entity/order/order.entity';
+import { createOrderFromLines, parseLines } from '../../testing/order-test-utils';
+
+import { UseGuestIfExistingEmptyStrategy } from './use-guest-if-existing-empty-strategy';
+
+describe('UseGuestIfExistingEmptyStrategy', () => {
+    const strategy = new UseGuestIfExistingEmptyStrategy();
+
+    it('both orders empty', () => {
+        const guestOrder = new Order({ lines: [] });
+        const existingOrder = new Order({ lines: [] });
+
+        const result = strategy.merge(guestOrder, existingOrder);
+
+        expect(result).toEqual([]);
+    });
+
+    it('existingOrder empty', () => {
+        const guestLines = [{ lineId: 1, quantity: 2, productVariantId: 100 }];
+        const guestOrder = createOrderFromLines(guestLines);
+        const existingOrder = new Order({ lines: [] });
+
+        const result = strategy.merge(guestOrder, existingOrder);
+
+        expect(parseLines(result)).toEqual(guestLines);
+    });
+
+    it('guestOrder empty', () => {
+        const existingLines = [{ lineId: 1, quantity: 2, productVariantId: 100 }];
+        const guestOrder = new Order({ lines: [] });
+        const existingOrder = createOrderFromLines(existingLines);
+
+        const result = strategy.merge(guestOrder, existingOrder);
+
+        expect(parseLines(result)).toEqual(existingLines);
+    });
+
+    it('both orders have non-conflicting lines', () => {
+        const guestLines = [
+            { lineId: 21, quantity: 2, productVariantId: 201 },
+            { lineId: 22, quantity: 1, productVariantId: 202 },
+        ];
+        const existingLines = [
+            { lineId: 1, quantity: 1, productVariantId: 101 },
+            { lineId: 2, quantity: 1, productVariantId: 102 },
+            { lineId: 3, quantity: 1, productVariantId: 103 },
+        ];
+        const guestOrder = createOrderFromLines(guestLines);
+        const existingOrder = createOrderFromLines(existingLines);
+
+        const result = strategy.merge(guestOrder, existingOrder);
+
+        expect(parseLines(result)).toEqual(existingLines);
+    });
+
+    it('both orders have conflicting lines, some of which conflict', () => {
+        const guestLines = [
+            { lineId: 21, quantity: 2, productVariantId: 102 },
+            { lineId: 22, quantity: 1, productVariantId: 202 },
+        ];
+        const existingLines = [
+            { lineId: 1, quantity: 1, productVariantId: 101 },
+            { lineId: 2, quantity: 1, productVariantId: 102 },
+            { lineId: 3, quantity: 1, productVariantId: 103 },
+        ];
+        const guestOrder = createOrderFromLines(guestLines);
+        const existingOrder = createOrderFromLines(existingLines);
+
+        const result = strategy.merge(guestOrder, existingOrder);
+
+        expect(parseLines(result)).toEqual(existingLines);
+    });
+
+    it('returns a new array', () => {
+        const guestLines = [{ lineId: 21, quantity: 2, productVariantId: 102 }];
+        const existingLines = [{ lineId: 1, quantity: 1, productVariantId: 101 }];
+        const guestOrder = createOrderFromLines(guestLines);
+        const existingOrder = createOrderFromLines(existingLines);
+
+        const result = strategy.merge(guestOrder, existingOrder);
+
+        expect(result).not.toBe(guestOrder.lines);
+        expect(result).not.toBe(existingOrder.lines);
+    });
+});

+ 10 - 0
server/src/config/order-merge-strategy/use-guest-if-existing-empty-strategy.ts

@@ -0,0 +1,10 @@
+import { OrderLine } from '../../entity/order-line/order-line.entity';
+import { Order } from '../../entity/order/order.entity';
+
+import { OrderMergeStrategy } from './order-merge-strategy';
+
+export class UseGuestIfExistingEmptyStrategy implements OrderMergeStrategy {
+    merge(guestOrder: Order, existingOrder: Order): OrderLine[] {
+        return existingOrder.lines.length ? existingOrder.lines.slice() : guestOrder.lines.slice();
+    }
+}

+ 85 - 0
server/src/config/order-merge-strategy/use-guest-strategy.spec.ts

@@ -0,0 +1,85 @@
+import { Order } from '../../entity/order/order.entity';
+import { createOrderFromLines, parseLines } from '../../testing/order-test-utils';
+
+import { UseGuestStrategy } from './use-guest-strategy';
+
+describe('UseGuestStrategy', () => {
+    const strategy = new UseGuestStrategy();
+
+    it('both orders empty', () => {
+        const guestOrder = new Order({ lines: [] });
+        const existingOrder = new Order({ lines: [] });
+
+        const result = strategy.merge(guestOrder, existingOrder);
+
+        expect(result).toEqual([]);
+    });
+
+    it('existingOrder empty', () => {
+        const guestLines = [{ lineId: 1, quantity: 2, productVariantId: 100 }];
+        const guestOrder = createOrderFromLines(guestLines);
+        const existingOrder = new Order({ lines: [] });
+
+        const result = strategy.merge(guestOrder, existingOrder);
+
+        expect(parseLines(result)).toEqual(guestLines);
+    });
+
+    it('guestOrder empty', () => {
+        const existingLines = [{ lineId: 1, quantity: 2, productVariantId: 100 }];
+        const guestOrder = new Order({ lines: [] });
+        const existingOrder = createOrderFromLines(existingLines);
+
+        const result = strategy.merge(guestOrder, existingOrder);
+
+        expect(parseLines(result)).toEqual([]);
+    });
+
+    it('both orders have non-conflicting lines', () => {
+        const guestLines = [
+            { lineId: 21, quantity: 2, productVariantId: 201 },
+            { lineId: 22, quantity: 1, productVariantId: 202 },
+        ];
+        const existingLines = [
+            { lineId: 1, quantity: 1, productVariantId: 101 },
+            { lineId: 2, quantity: 1, productVariantId: 102 },
+            { lineId: 3, quantity: 1, productVariantId: 103 },
+        ];
+        const guestOrder = createOrderFromLines(guestLines);
+        const existingOrder = createOrderFromLines(existingLines);
+
+        const result = strategy.merge(guestOrder, existingOrder);
+
+        expect(parseLines(result)).toEqual(guestLines);
+    });
+
+    it('both orders have conflicting lines, some of which conflict', () => {
+        const guestLines = [
+            { lineId: 21, quantity: 2, productVariantId: 102 },
+            { lineId: 22, quantity: 1, productVariantId: 202 },
+        ];
+        const existingLines = [
+            { lineId: 1, quantity: 1, productVariantId: 101 },
+            { lineId: 2, quantity: 1, productVariantId: 102 },
+            { lineId: 3, quantity: 1, productVariantId: 103 },
+        ];
+        const guestOrder = createOrderFromLines(guestLines);
+        const existingOrder = createOrderFromLines(existingLines);
+
+        const result = strategy.merge(guestOrder, existingOrder);
+
+        expect(parseLines(result)).toEqual(guestLines);
+    });
+
+    it('returns a new array', () => {
+        const guestLines = [{ lineId: 21, quantity: 2, productVariantId: 102 }];
+        const existingLines = [{ lineId: 1, quantity: 1, productVariantId: 101 }];
+        const guestOrder = createOrderFromLines(guestLines);
+        const existingOrder = createOrderFromLines(existingLines);
+
+        const result = strategy.merge(guestOrder, existingOrder);
+
+        expect(result).not.toBe(guestOrder.lines);
+        expect(result).not.toBe(existingOrder.lines);
+    });
+});

+ 10 - 0
server/src/config/order-merge-strategy/use-guest-strategy.ts

@@ -0,0 +1,10 @@
+import { OrderLine } from '../../entity/order-line/order-line.entity';
+import { Order } from '../../entity/order/order.entity';
+
+import { OrderMergeStrategy } from './order-merge-strategy';
+
+export class UseGuestStrategy implements OrderMergeStrategy {
+    merge(guestOrder: Order, existingOrder: Order): OrderLine[] {
+        return guestOrder.lines.slice();
+    }
+}

+ 19 - 0
server/src/config/vendure-config.ts

@@ -16,6 +16,7 @@ import { AssetStorageStrategy } from './asset-storage-strategy/asset-storage-str
 import { defaultConfig } from './default-config';
 import { EntityIdStrategy } from './entity-id-strategy/entity-id-strategy';
 import { mergeConfig } from './merge-config';
+import { OrderMergeStrategy } from './order-merge-strategy/order-merge-strategy';
 import { PromotionAction } from './promotion/promotion-action';
 import { PromotionCondition } from './promotion/promotion-condition';
 import { VendurePlugin } from './vendure-plugin/vendure-plugin';
@@ -86,6 +87,19 @@ export interface OrderProcessOptions<T extends string> {
     onError?(fromState: T, toState: T, message?: string): void;
 }
 
+export interface OrderMergeOptions {
+    /**
+     * Defines the strategy used to merge a guest Order and an existing Order when
+     * signing in.
+     */
+    mergeStrategy: OrderMergeStrategy;
+    /**
+     * Defines the strategy used to merge a guest Order and an existing Order when
+     * signing in as part of the checkout flow.
+     */
+    checkoutMergeStrategy: OrderMergeStrategy;
+}
+
 export interface VendureConfig {
     /**
      * The name of the property which contains the token of the
@@ -160,6 +174,11 @@ export interface VendureConfig {
      * Defines custom states in the order process finite state machine.
      */
     orderProcessOptions?: OrderProcessOptions<any>;
+    /**
+     * Define the strategies governing how Orders are merged when an existing
+     * Customer signs in.
+     */
+    orderMergeOptions?: OrderMergeOptions;
     /**
      * The max file size in bytes for uploaded assets.
      */

+ 1 - 1
server/src/entity/order-line/order-line.entity.ts

@@ -28,7 +28,7 @@ export class OrderLine extends VendureEntity {
     @OneToMany(type => OrderItem, item => item.line)
     items: OrderItem[];
 
-    @ManyToOne(type => Order, order => order.lines)
+    @ManyToOne(type => Order, order => order.lines, { onDelete: 'CASCADE' })
     order: Order;
 
     @Calculated()

+ 91 - 0
server/src/service/helpers/order-merger/order-merger.spec.ts

@@ -0,0 +1,91 @@
+import { Test } from '@nestjs/testing';
+
+import { ConfigService } from '../../../config/config.service';
+import { MockConfigService } from '../../../config/config.service.mock';
+import { MergeOrdersStrategy } from '../../../config/order-merge-strategy/merge-orders-strategy';
+import { Order } from '../../../entity/order/order.entity';
+import { createOrderFromLines } from '../../../testing/order-test-utils';
+
+import { OrderMerger } from './order-merger';
+
+describe('OrderMerger', () => {
+    let orderMerger: OrderMerger;
+
+    beforeEach(async () => {
+        const module = await Test.createTestingModule({
+            providers: [OrderMerger, { provide: ConfigService, useClass: MockConfigService }],
+        }).compile();
+        const mockConfigService = module.get<ConfigService, MockConfigService>(ConfigService);
+        mockConfigService.orderMergeOptions = {
+            mergeStrategy: new MergeOrdersStrategy(),
+        };
+        orderMerger = module.get(OrderMerger);
+    });
+
+    it('both orders undefined', () => {
+        const guestOrder = new Order({ lines: [] });
+        const existingOrder = new Order({ lines: [] });
+
+        const result = orderMerger.merge();
+
+        expect(result.order).toBeUndefined();
+        expect(result.linesToInsert).toBeUndefined();
+        expect(result.orderToDelete).toBeUndefined();
+    });
+
+    it('guestOrder undefined', () => {
+        const existingOrder = createOrderFromLines([{ lineId: 1, quantity: 2, productVariantId: 100 }]);
+
+        const result = orderMerger.merge(undefined, existingOrder);
+
+        expect(result.order).toBe(existingOrder);
+        expect(result.linesToInsert).toBeUndefined();
+        expect(result.orderToDelete).toBeUndefined();
+    });
+
+    it('existingOrder undefined', () => {
+        const guestOrder = createOrderFromLines([{ lineId: 1, quantity: 2, productVariantId: 100 }]);
+
+        const result = orderMerger.merge(guestOrder, undefined);
+
+        expect(result.order).toBe(guestOrder);
+        expect(result.linesToInsert).toBeUndefined();
+        expect(result.orderToDelete).toBeUndefined();
+    });
+
+    it('empty guestOrder', () => {
+        const guestOrder = createOrderFromLines([]);
+        guestOrder.id = 42;
+        const existingOrder = createOrderFromLines([{ lineId: 1, quantity: 2, productVariantId: 100 }]);
+
+        const result = orderMerger.merge(guestOrder, existingOrder);
+
+        expect(result.order).toBe(existingOrder);
+        expect(result.linesToInsert).toBeUndefined();
+        expect(result.orderToDelete).toBe(guestOrder);
+    });
+
+    it('empty existingOrder', () => {
+        const guestOrder = createOrderFromLines([{ lineId: 1, quantity: 2, productVariantId: 100 }]);
+        const existingOrder = createOrderFromLines([]);
+        existingOrder.id = 42;
+
+        const result = orderMerger.merge(guestOrder, existingOrder);
+
+        expect(result.order).toBe(guestOrder);
+        expect(result.linesToInsert).toBeUndefined();
+        expect(result.orderToDelete).toBe(existingOrder);
+    });
+
+    it('new lines added by merge', () => {
+        const guestOrder = createOrderFromLines([{ lineId: 20, quantity: 2, productVariantId: 200 }]);
+        guestOrder.id = 42;
+        const existingOrder = createOrderFromLines([{ lineId: 1, quantity: 2, productVariantId: 100 }]);
+
+        const result = orderMerger.merge(guestOrder, existingOrder);
+
+        expect(result.order).toBe(existingOrder);
+        expect(result.linesToInsert).toEqual([{ productVariantId: 200, quantity: 2 }]);
+        expect(result.orderToDelete).toBe(guestOrder);
+    });
+});

+ 72 - 0
server/src/service/helpers/order-merger/order-merger.ts

@@ -0,0 +1,72 @@
+import { Injectable } from '@nestjs/common';
+import { ID } from 'shared/shared-types';
+
+import { ConfigService } from '../../../config/config.service';
+import { OrderLine } from '../../../entity/order-line/order-line.entity';
+import { Order } from '../../../entity/order/order.entity';
+
+export type OrderWithNoLines = Order & { lines: undefined };
+export type OrderWithEmptyLines = Order & { lines: ArrayLike<OrderLine> & { length: 0 } };
+export type EmptyOrder = OrderWithEmptyLines | OrderWithNoLines;
+export type MergeResult = {
+    order?: Order;
+    linesToInsert?: Array<{ productVariantId: ID; quantity: number }>;
+    orderToDelete?: Order;
+};
+
+@Injectable()
+export class OrderMerger {
+    constructor(private configService: ConfigService) {}
+
+    /**
+     * Applies the configured OrderMergeStrategy to the supplied guestOrder and existingOrder. Returns an object
+     * containing entities which then need to be persisted to the database by the OrderService methods.
+     */
+    merge(guestOrder?: Order, existingOrder?: Order): MergeResult {
+        if (guestOrder && !this.orderEmpty(guestOrder) && existingOrder && !this.orderEmpty(existingOrder)) {
+            const { mergeStrategy } = this.configService.orderMergeOptions;
+            const mergedLines = mergeStrategy.merge(guestOrder, existingOrder);
+            return {
+                order: existingOrder,
+                linesToInsert: this.getLinesToInsert(guestOrder, existingOrder, mergedLines),
+                orderToDelete: guestOrder,
+            };
+        } else if (
+            guestOrder &&
+            !this.orderEmpty(guestOrder) &&
+            (!existingOrder || (existingOrder && this.orderEmpty(existingOrder)))
+        ) {
+            return {
+                order: guestOrder,
+                orderToDelete: existingOrder,
+            };
+        } else {
+            return {
+                order: existingOrder,
+                orderToDelete: guestOrder,
+            };
+        }
+    }
+
+    private getLinesToInsert(
+        guestOrder: Order,
+        existingOrder: Order,
+        mergedLines: OrderLine[],
+    ): Array<{ productVariantId: ID; quantity: number }> {
+        const linesToInsert: Array<{ productVariantId: ID; quantity: number }> = [];
+        for (const line of mergedLines) {
+            if (
+                !existingOrder.lines.find(
+                    existingLine => existingLine.productVariant.id === line.productVariant.id,
+                )
+            ) {
+                linesToInsert.push({ productVariantId: line.productVariant.id, quantity: line.quantity });
+            }
+        }
+        return linesToInsert;
+    }
+
+    private orderEmpty(order: Order | EmptyOrder): order is EmptyOrder {
+        return !order || !order.lines || !order.lines.length;
+    }
+}

+ 2 - 0
server/src/service/service.module.ts

@@ -6,6 +6,7 @@ import { getConfig } from '../config/vendure-config';
 
 import { ListQueryBuilder } from './helpers/list-query-builder/list-query-builder';
 import { OrderCalculator } from './helpers/order-calculator/order-calculator';
+import { OrderMerger } from './helpers/order-merger/order-merger';
 import { OrderStateMachine } from './helpers/order-state-machine/order-state-machine';
 import { PasswordCiper } from './helpers/password-cipher/password-ciper';
 import { TaxCalculator } from './helpers/tax-calculator/tax-calculator';
@@ -68,6 +69,7 @@ const exportedProviders = [
         TaxCalculator,
         OrderCalculator,
         OrderStateMachine,
+        OrderMerger,
         ListQueryBuilder,
     ],
     exports: exportedProviders,

+ 38 - 17
server/src/service/services/auth.service.ts

@@ -45,17 +45,11 @@ export class AuthService {
         if (!passwordMatches) {
             throw new UnauthorizedException();
         }
-        const token = await this.generateSessionToken();
-        const activeOrder = await this.orderService.getActiveOrderForUser(ctx, user.id);
-        const session = new AuthenticatedSession({
-            token,
-            user,
-            activeOrder,
-            expires: this.getExpiryDate(this.sessionDurationInMs),
-            invalidated: false,
-        });
-        await this.invalidateUserSessions(user);
-        // save the new session
+        await this.deleteSessionsByUser(user);
+        if (ctx.session && ctx.session.activeOrder) {
+            await this.deleteSessionsByActiveOrder(ctx.session && ctx.session.activeOrder);
+        }
+        const session = await this.createNewAuthenticatedSession(ctx, user);
         const newSession = await this.connection.getRepository(AuthenticatedSession).save(session);
         return newSession;
     }
@@ -96,22 +90,29 @@ export class AuthService {
     }
 
     /**
-     * Invalidates all existing sessions for the given user.
+     * Deletes all existing sessions for the given user.
+     */
+    async deleteSessionsByUser(user: User): Promise<void> {
+        await this.connection.getRepository(AuthenticatedSession).delete({ user });
+    }
+
+    /**
+     * Deletes all existing sessions with the given activeOrder.
      */
-    async invalidateUserSessions(user: User): Promise<void> {
-        await this.connection.getRepository(AuthenticatedSession).update({ user }, { invalidated: true });
+    async deleteSessionsByActiveOrder(activeOrder: Order): Promise<void> {
+        await this.connection.getRepository(Session).delete({ activeOrder });
     }
 
     /**
-     * Invalidates all sessions for the user associated with the given session token.
+     * Deletes all sessions for the user associated with the given session token.
      */
-    async invalidateSessionByToken(token: string): Promise<void> {
+    async deleteSessionByToken(token: string): Promise<void> {
         const session = await this.connection.getRepository(AuthenticatedSession).findOne({
             where: { token },
             relations: ['user'],
         });
         if (session) {
-            return this.invalidateUserSessions(session.user);
+            return this.deleteSessionsByUser(session.user);
         }
     }
 
@@ -121,6 +122,26 @@ export class AuthService {
         });
     }
 
+    private async createNewAuthenticatedSession(
+        ctx: RequestContext,
+        user: User,
+    ): Promise<AuthenticatedSession> {
+        const token = await this.generateSessionToken();
+        const guestOrder =
+            ctx.session && ctx.session.activeOrder
+                ? await this.orderService.findOne(ctx, ctx.session.activeOrder.id)
+                : undefined;
+        const existingOrder = await this.orderService.getActiveOrderForUser(ctx, user.id);
+        const activeOrder = await this.orderService.mergeOrders(ctx, user, guestOrder, existingOrder);
+        return new AuthenticatedSession({
+            token,
+            user,
+            activeOrder,
+            expires: this.getExpiryDate(this.sessionDurationInMs),
+            invalidated: false,
+        });
+    }
+
     private async getUserFromIdentifier(identifier: string): Promise<User> {
         const user = await this.connection.getRepository(User).findOne({
             where: { identifier },

+ 32 - 0
server/src/service/services/order.service.ts

@@ -11,9 +11,11 @@ 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 { User } from '../../entity/user/user.entity';
 import { I18nError } from '../../i18n/i18n-error';
 import { ListQueryBuilder } from '../helpers/list-query-builder/list-query-builder';
 import { OrderCalculator } from '../helpers/order-calculator/order-calculator';
+import { OrderMerger } from '../helpers/order-merger/order-merger';
 import { OrderState } from '../helpers/order-state-machine/order-state';
 import { OrderStateMachine } from '../helpers/order-state-machine/order-state-machine';
 import { translateDeep } from '../helpers/utils/translate-entity';
@@ -28,6 +30,7 @@ export class OrderService {
         private customerService: CustomerService,
         private orderCalculator: OrderCalculator,
         private orderStateMachine: OrderStateMachine,
+        private orderMerger: OrderMerger,
         private listQueryBuilder: ListQueryBuilder,
     ) {}
 
@@ -171,6 +174,35 @@ export class OrderService {
         return order;
     }
 
+    /**
+     * When a guest user with an anonymous Order signs in and has an existing Order associated with that Customer,
+     * we need to reconcile the contents of the two orders.
+     */
+    async mergeOrders(
+        ctx: RequestContext,
+        user: User,
+        guestOrder?: Order,
+        existingOrder?: Order,
+    ): Promise<Order | undefined> {
+        const mergeResult = await this.orderMerger.merge(guestOrder, existingOrder);
+        const { orderToDelete, linesToInsert } = mergeResult;
+        let { order } = mergeResult;
+        if (orderToDelete) {
+            await this.connection.getRepository(Order).delete(orderToDelete.id);
+        }
+        if (order && linesToInsert) {
+            for (const line of linesToInsert) {
+                order = await this.addItemToOrder(ctx, order.id, line.productVariantId, line.quantity);
+            }
+        }
+        const customer = await this.customerService.findOneByUserId(user.id);
+        if (order && customer) {
+            order.customer = customer;
+            await this.connection.getRepository(Order).save(order);
+        }
+        return order;
+    }
+
     private async getOrderOrThrow(ctx: RequestContext, orderId: ID): Promise<Order> {
         const order = await this.findOne(ctx, orderId);
         if (!order) {

+ 33 - 0
server/src/testing/order-test-utils.ts

@@ -0,0 +1,33 @@
+import { ID } from 'shared/shared-types';
+
+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';
+
+export type SimpleLine = { productVariantId: ID; quantity: number; lineId: ID };
+
+export function createOrderFromLines(simpleLines: SimpleLine[]): Order {
+    const lines = simpleLines.map(
+        ({ productVariantId, quantity, lineId }) =>
+            new OrderLine({
+                id: lineId,
+                productVariant: new ProductVariant({ id: productVariantId }),
+                items: Array.from({ length: quantity }).map(() => new OrderItem({})),
+            }),
+    );
+
+    return new Order({
+        lines,
+    });
+}
+
+export function parseLines(lines: OrderLine[]): SimpleLine[] {
+    return lines.map(line => {
+        return {
+            lineId: line.id,
+            productVariantId: line.productVariant.id,
+            quantity: line.quantity,
+        };
+    });
+}