Skip to content

Commit

Permalink
feat(core): Implement ChangedPriceHandlingStrategy
Browse files Browse the repository at this point in the history
Relates to #664

BREAKING CHANGE: The OrderItem entity has a new field, `initialListPrice`, used to better
handle price changes to items in an active Order. This schema change will require a DB migration.
  • Loading branch information
michaelbromley committed Feb 9, 2021
1 parent e4d3aed commit 3aae4fb
Show file tree
Hide file tree
Showing 20 changed files with 385 additions and 11 deletions.
4 changes: 4 additions & 0 deletions packages/admin-ui/src/lib/core/src/common/generated-types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3926,6 +3926,10 @@ export type OrderLine = Node & {
unitPrice: Scalars['Int'];
/** The price of a single unit, including tax but excluding discounts */
unitPriceWithTax: Scalars['Int'];
/** If the unitPrice has changed since initially added to Order */
unitPriceChangeSinceAdded: Scalars['Int'];
/** If the unitPriceWithTax has changed since initially added to Order */
unitPriceWithTaxChangeSinceAdded: Scalars['Int'];
/**
* The price of a single unit including discounts, excluding tax.
*
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3671,6 +3671,10 @@ export type OrderLine = Node & {
unitPrice: Scalars['Int'];
/** The price of a single unit, including tax but excluding discounts */
unitPriceWithTax: Scalars['Int'];
/** If the unitPrice has changed since initially added to Order */
unitPriceChangeSinceAdded: Scalars['Int'];
/** If the unitPriceWithTax has changed since initially added to Order */
unitPriceWithTaxChangeSinceAdded: Scalars['Int'];
/**
* The price of a single unit including discounts, excluding tax.
*
Expand Down
4 changes: 4 additions & 0 deletions packages/common/src/generated-shop-types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1908,6 +1908,10 @@ export type OrderLine = Node & {
unitPrice: Scalars['Int'];
/** The price of a single unit, including tax but excluding discounts */
unitPriceWithTax: Scalars['Int'];
/** If the unitPrice has changed since initially added to Order */
unitPriceChangeSinceAdded: Scalars['Int'];
/** If the unitPriceWithTax has changed since initially added to Order */
unitPriceWithTaxChangeSinceAdded: Scalars['Int'];
/**
* The price of a single unit including discounts, excluding tax.
*
Expand Down
4 changes: 4 additions & 0 deletions packages/common/src/generated-types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3888,6 +3888,10 @@ export type OrderLine = Node & {
unitPrice: Scalars['Int'];
/** The price of a single unit, including tax but excluding discounts */
unitPriceWithTax: Scalars['Int'];
/** If the unitPrice has changed since initially added to Order */
unitPriceChangeSinceAdded: Scalars['Int'];
/** If the unitPriceWithTax has changed since initially added to Order */
unitPriceWithTaxChangeSinceAdded: Scalars['Int'];
/**
* The price of a single unit including discounts, excluding tax.
*
Expand Down
4 changes: 4 additions & 0 deletions packages/core/e2e/graphql/generated-e2e-admin-types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3671,6 +3671,10 @@ export type OrderLine = Node & {
unitPrice: Scalars['Int'];
/** The price of a single unit, including tax but excluding discounts */
unitPriceWithTax: Scalars['Int'];
/** If the unitPrice has changed since initially added to Order */
unitPriceChangeSinceAdded: Scalars['Int'];
/** If the unitPriceWithTax has changed since initially added to Order */
unitPriceWithTaxChangeSinceAdded: Scalars['Int'];
/**
* The price of a single unit including discounts, excluding tax.
*
Expand Down
15 changes: 13 additions & 2 deletions packages/core/e2e/graphql/generated-e2e-shop-types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1848,6 +1848,10 @@ export type OrderLine = Node & {
unitPrice: Scalars['Int'];
/** The price of a single unit, including tax but excluding discounts */
unitPriceWithTax: Scalars['Int'];
/** If the unitPrice has changed since initially added to Order */
unitPriceChangeSinceAdded: Scalars['Int'];
/** If the unitPriceWithTax has changed since initially added to Order */
unitPriceWithTaxChangeSinceAdded: Scalars['Int'];
/**
* The price of a single unit including discounts, excluding tax.
*
Expand Down Expand Up @@ -2709,11 +2713,18 @@ export type TestOrderFragmentFragment = Pick<
lines: Array<
Pick<
OrderLine,
'id' | 'quantity' | 'linePrice' | 'linePriceWithTax' | 'unitPrice' | 'unitPriceWithTax'
| 'id'
| 'quantity'
| 'linePrice'
| 'linePriceWithTax'
| 'unitPrice'
| 'unitPriceWithTax'
| 'unitPriceChangeSinceAdded'
| 'unitPriceWithTaxChangeSinceAdded'
> & {
productVariant: Pick<ProductVariant, 'id'>;
discounts: Array<Pick<Adjustment, 'adjustmentSource' | 'amount' | 'description' | 'type'>>;
items: Array<Pick<OrderItem, 'id'>>;
items: Array<Pick<OrderItem, 'id' | 'unitPrice' | 'unitPriceWithTax'>>;
}
>;
shippingLines: Array<{ shippingMethod: Pick<ShippingMethod, 'id' | 'code' | 'description'> }>;
Expand Down
4 changes: 4 additions & 0 deletions packages/core/e2e/graphql/shop-definitions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,8 @@ export const TEST_ORDER_FRAGMENT = gql`
linePriceWithTax
unitPrice
unitPriceWithTax
unitPriceChangeSinceAdded
unitPriceWithTaxChangeSinceAdded
productVariant {
id
}
Expand All @@ -39,6 +41,8 @@ export const TEST_ORDER_FRAGMENT = gql`
}
items {
id
unitPrice
unitPriceWithTax
}
}
shippingLines {
Expand Down
217 changes: 217 additions & 0 deletions packages/core/e2e/order-changed-price-handling.e2e-spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,217 @@
/* tslint:disable:no-non-null-assertion */
import {
ChangedPriceHandlingStrategy,
mergeConfig,
OrderItem,
PriceCalculationResult,
RequestContext,
} from '@vendure/core';
import { createTestEnvironment } from '@vendure/testing';
import path from 'path';

import { initialData } from '../../../e2e-common/e2e-initial-data';
import { testConfig, TEST_SETUP_TIMEOUT_MS } from '../../../e2e-common/test-config';

import { UpdateProductVariants } from './graphql/generated-e2e-admin-types';
import { AddItemToOrder, AdjustItemQuantity, GetActiveOrder } from './graphql/generated-e2e-shop-types';
import { UPDATE_PRODUCT_VARIANTS } from './graphql/shared-definitions';
import { ADD_ITEM_TO_ORDER, ADJUST_ITEM_QUANTITY, GET_ACTIVE_ORDER } from './graphql/shop-definitions';

class TestChangedPriceStrategy implements ChangedPriceHandlingStrategy {
static spy = jest.fn();
static useLatestPrice = true;

handlePriceChange(
ctx: RequestContext,
current: PriceCalculationResult,
existingItems: OrderItem[],
): PriceCalculationResult {
TestChangedPriceStrategy.spy(current);
if (TestChangedPriceStrategy.useLatestPrice) {
return current;
} else {
return {
price: existingItems[0].listPrice,
priceIncludesTax: existingItems[0].listPriceIncludesTax,
};
}
}
}

describe('ChangedPriceHandlingStrategy', () => {
const { server, shopClient, adminClient } = createTestEnvironment(
mergeConfig(testConfig, {
orderOptions: {
changedPriceHandlingStrategy: new TestChangedPriceStrategy(),
},
}),
);

beforeAll(async () => {
await server.init({
initialData,
productsCsvPath: path.join(__dirname, 'fixtures/e2e-products-full.csv'),
customerCount: 1,
});
await adminClient.asSuperAdmin();
}, TEST_SETUP_TIMEOUT_MS);

afterAll(async () => {
await server.destroy();
});

it('unitPriceChangeSinceAdded starts as 0', async () => {
TestChangedPriceStrategy.spy.mockClear();

await shopClient.query<AddItemToOrder.Mutation, AddItemToOrder.Variables>(ADD_ITEM_TO_ORDER, {
productVariantId: 'T_12',
quantity: 1,
});

const { activeOrder } = await shopClient.query<GetActiveOrder.Query>(GET_ACTIVE_ORDER);

expect(activeOrder?.lines[0].unitPriceChangeSinceAdded).toBe(0);
expect(activeOrder?.lines[0].unitPrice).toBe(5374);
expect(TestChangedPriceStrategy.spy).not.toHaveBeenCalled();
});

describe('use latest price', () => {
let firstOrderLineId: string;

beforeAll(() => {
TestChangedPriceStrategy.useLatestPrice = true;
});

it('calls handlePriceChange on addItemToOrder', async () => {
TestChangedPriceStrategy.spy.mockClear();

await adminClient.query<UpdateProductVariants.Mutation, UpdateProductVariants.Variables>(
UPDATE_PRODUCT_VARIANTS,
{
input: [
{
id: 'T_12',
price: 6000,
},
],
},
);

await shopClient.query<AddItemToOrder.Mutation, AddItemToOrder.Variables>(ADD_ITEM_TO_ORDER, {
productVariantId: 'T_12',
quantity: 1,
});

const { activeOrder } = await shopClient.query<GetActiveOrder.Query>(GET_ACTIVE_ORDER);
expect(activeOrder?.lines[0].unitPriceChangeSinceAdded).toBe(626);
expect(activeOrder?.lines[0].unitPrice).toBe(6000);
expect(activeOrder?.lines[0].items.every(i => i.unitPrice === 6000)).toBe(true);
expect(TestChangedPriceStrategy.spy).toHaveBeenCalledTimes(1);

firstOrderLineId = activeOrder!.lines[0].id;
});

it('calls handlePriceChange on adjustOrderLine', async () => {
TestChangedPriceStrategy.spy.mockClear();

await adminClient.query<UpdateProductVariants.Mutation, UpdateProductVariants.Variables>(
UPDATE_PRODUCT_VARIANTS,
{
input: [
{
id: 'T_12',
price: 3000,
},
],
},
);

await shopClient.query<AdjustItemQuantity.Mutation, AdjustItemQuantity.Variables>(
ADJUST_ITEM_QUANTITY,
{
orderLineId: firstOrderLineId,
quantity: 3,
},
);

const { activeOrder } = await shopClient.query<GetActiveOrder.Query>(GET_ACTIVE_ORDER);
expect(activeOrder?.lines[0].unitPriceChangeSinceAdded).toBe(-2374);
expect(activeOrder?.lines[0].unitPrice).toBe(3000);
expect(activeOrder?.lines[0].items.every(i => i.unitPrice === 3000)).toBe(true);
expect(TestChangedPriceStrategy.spy).toHaveBeenCalledTimes(1);
});
});

describe('use original price', () => {
let secondOrderLineId: string;
const ORIGINAL_PRICE = 7896;

beforeAll(() => {
TestChangedPriceStrategy.useLatestPrice = false;
});

it('calls handlePriceChange on addItemToOrder', async () => {
TestChangedPriceStrategy.spy.mockClear();

await shopClient.query<AddItemToOrder.Mutation, AddItemToOrder.Variables>(ADD_ITEM_TO_ORDER, {
productVariantId: 'T_13',
quantity: 1,
});

await adminClient.query<UpdateProductVariants.Mutation, UpdateProductVariants.Variables>(
UPDATE_PRODUCT_VARIANTS,
{
input: [
{
id: 'T_13',
price: 8000,
},
],
},
);

await shopClient.query<AddItemToOrder.Mutation, AddItemToOrder.Variables>(ADD_ITEM_TO_ORDER, {
productVariantId: 'T_13',
quantity: 1,
});

const { activeOrder } = await shopClient.query<GetActiveOrder.Query>(GET_ACTIVE_ORDER);
expect(activeOrder?.lines[1].unitPriceChangeSinceAdded).toBe(0);
expect(activeOrder?.lines[1].unitPrice).toBe(ORIGINAL_PRICE);
expect(activeOrder?.lines[1].items.every(i => i.unitPrice === ORIGINAL_PRICE)).toBe(true);
expect(TestChangedPriceStrategy.spy).toHaveBeenCalledTimes(1);

secondOrderLineId = activeOrder!.lines[1].id;
});

it('calls handlePriceChange on adjustOrderLine', async () => {
TestChangedPriceStrategy.spy.mockClear();

await adminClient.query<UpdateProductVariants.Mutation, UpdateProductVariants.Variables>(
UPDATE_PRODUCT_VARIANTS,
{
input: [
{
id: 'T_13',
price: 3000,
},
],
},
);

await shopClient.query<AdjustItemQuantity.Mutation, AdjustItemQuantity.Variables>(
ADJUST_ITEM_QUANTITY,
{
orderLineId: secondOrderLineId,
quantity: 3,
},
);

const { activeOrder } = await shopClient.query<GetActiveOrder.Query>(GET_ACTIVE_ORDER);
expect(activeOrder?.lines[1].unitPriceChangeSinceAdded).toBe(0);
expect(activeOrder?.lines[1].unitPrice).toBe(ORIGINAL_PRICE);
expect(activeOrder?.lines[1].items.every(i => i.unitPrice === ORIGINAL_PRICE)).toBe(true);
expect(TestChangedPriceStrategy.spy).toHaveBeenCalledTimes(1);
});
});
});
8 changes: 8 additions & 0 deletions packages/core/src/api/schema/common/order.type.graphql
Original file line number Diff line number Diff line change
Expand Up @@ -151,6 +151,14 @@ type OrderLine implements Node {
"The price of a single unit, including tax but excluding discounts"
unitPriceWithTax: Int!
"""
Non-zero if the unitPrice has changed since it was initially added to Order
"""
unitPriceChangeSinceAdded: Int!
"""
Non-zero if the unitPriceWithTax has changed since it was initially added to Order
"""
unitPriceWithTaxChangeSinceAdded: Int!
"""
The price of a single unit including discounts, excluding tax.
If Order-level discounts have been applied, this will not be the
Expand Down
2 changes: 2 additions & 0 deletions packages/core/src/config/default-config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ import { DefaultProductVariantPriceCalculationStrategy } from './catalog/default
import { AutoIncrementIdStrategy } from './entity-id-strategy/auto-increment-id-strategy';
import { manualFulfillmentHandler } from './fulfillment/manual-fulfillment-handler';
import { DefaultLogger } from './logger/default-logger';
import { DefaultChangedPriceHandlingStrategy } from './order/default-changed-price-handling-strategy';
import { DefaultOrderItemPriceCalculationStrategy } from './order/default-order-item-price-calculation-strategy';
import { DefaultStockAllocationStrategy } from './order/default-stock-allocation-strategy';
import { MergeOrdersStrategy } from './order/merge-orders-strategy';
Expand Down Expand Up @@ -113,6 +114,7 @@ export const defaultConfig: RuntimeVendureConfig = {
process: [],
stockAllocationStrategy: new DefaultStockAllocationStrategy(),
orderCodeStrategy: new DefaultOrderCodeStrategy(),
changedPriceHandlingStrategy: new DefaultChangedPriceHandlingStrategy(),
},
paymentOptions: {
paymentMethodEligibilityCheckers: [],
Expand Down
1 change: 1 addition & 0 deletions packages/core/src/config/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ export * from './logger/noop-logger';
export * from './logger/vendure-logger';
export * from './merge-config';
export * from './order/custom-order-process';
export * from './order/changed-price-handling-strategy';
export * from './order/default-stock-allocation-strategy';
export * from './order/merge-orders-strategy';
export * from './order/order-code-strategy';
Expand Down
29 changes: 29 additions & 0 deletions packages/core/src/config/order/changed-price-handling-strategy.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
import { RequestContext } from '../../api/common/request-context';
import { PriceCalculationResult } from '../../common/types/common-types';
import { InjectableStrategy } from '../../common/types/injectable-strategy';
import { OrderItem } from '../../entity/order-item/order-item.entity';

/**
* @description
* This strategy defines how we handle the situation where an OrderItem exists in an Order, and
* then later on another is added but in the mean time the price of the ProductVariant has changed.
*
* By default, the latest price will be used. Any price changes resulting from using a newer price
* will be reflected in the GraphQL `OrderLine.unitPrice[WithTax]ChangeSinceAdded` field.
*
* @docsCategory orders
*/
export interface ChangedPriceHandlingStrategy extends InjectableStrategy {
/**
* @description
* This method is called when adding to or adjusting OrderLines, if the latest price
* (as determined by the ProductVariant price, potentially modified by the configured
* {@link OrderItemPriceCalculationStrategy}) differs from the initial price at the time
* that the OrderLine was created.
*/
handlePriceChange(
ctx: RequestContext,
current: PriceCalculationResult,
existingItems: OrderItem[],
): PriceCalculationResult | Promise<PriceCalculationResult>;
}
Loading

0 comments on commit 3aae4fb

Please sign in to comment.