stock-control.e2e-spec.ts 36 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880
  1. /* tslint:disable:no-non-null-assertion */
  2. import { manualFulfillmentHandler, mergeConfig, OrderState } from '@vendure/core';
  3. import { createErrorResultGuard, createTestEnvironment, ErrorResultGuard } from '@vendure/testing';
  4. import gql from 'graphql-tag';
  5. import path from 'path';
  6. import { initialData } from '../../../e2e-common/e2e-initial-data';
  7. import { testConfig, TEST_SETUP_TIMEOUT_MS } from '../../../e2e-common/test-config';
  8. import { testSuccessfulPaymentMethod, twoStagePaymentMethod } from './fixtures/test-payment-methods';
  9. import { VARIANT_WITH_STOCK_FRAGMENT } from './graphql/fragments';
  10. import {
  11. CancelOrder,
  12. CreateAddressInput,
  13. CreateFulfillment,
  14. ErrorCode as AdminErrorCode,
  15. FulfillmentFragment,
  16. GetOrder,
  17. GetStockMovement,
  18. GlobalFlag,
  19. SettlePayment,
  20. StockMovementType,
  21. UpdateGlobalSettings,
  22. UpdateProductVariantInput,
  23. UpdateProductVariants,
  24. UpdateStock,
  25. VariantWithStockFragment,
  26. } from './graphql/generated-e2e-admin-types';
  27. import {
  28. AddItemToOrder,
  29. AddPaymentToOrder,
  30. ErrorCode,
  31. PaymentInput,
  32. SetShippingAddress,
  33. TestOrderFragmentFragment,
  34. TestOrderWithPaymentsFragment,
  35. TransitionToState,
  36. UpdatedOrderFragment,
  37. } from './graphql/generated-e2e-shop-types';
  38. import {
  39. CANCEL_ORDER,
  40. CREATE_FULFILLMENT,
  41. GET_ORDER,
  42. GET_STOCK_MOVEMENT,
  43. SETTLE_PAYMENT,
  44. UPDATE_GLOBAL_SETTINGS,
  45. UPDATE_PRODUCT_VARIANTS,
  46. } from './graphql/shared-definitions';
  47. import {
  48. ADD_ITEM_TO_ORDER,
  49. ADD_PAYMENT,
  50. SET_SHIPPING_ADDRESS,
  51. TRANSITION_TO_STATE,
  52. } from './graphql/shop-definitions';
  53. import { assertThrowsWithMessage } from './utils/assert-throws-with-message';
  54. import { addPaymentToOrder, proceedToArrangingPayment } from './utils/test-order-utils';
  55. describe('Stock control', () => {
  56. const { server, adminClient, shopClient } = createTestEnvironment(
  57. mergeConfig(testConfig, {
  58. paymentOptions: {
  59. paymentMethodHandlers: [testSuccessfulPaymentMethod, twoStagePaymentMethod],
  60. },
  61. }),
  62. );
  63. const orderGuard: ErrorResultGuard<
  64. TestOrderFragmentFragment | UpdatedOrderFragment
  65. > = createErrorResultGuard(input => !!input.lines);
  66. const fulfillmentGuard: ErrorResultGuard<FulfillmentFragment> = createErrorResultGuard(
  67. input => !!input.state,
  68. );
  69. async function getProductWithStockMovement(productId: string) {
  70. const { product } = await adminClient.query<GetStockMovement.Query, GetStockMovement.Variables>(
  71. GET_STOCK_MOVEMENT,
  72. { id: productId },
  73. );
  74. return product;
  75. }
  76. beforeAll(async () => {
  77. await server.init({
  78. initialData: {
  79. ...initialData,
  80. paymentMethods: [
  81. {
  82. name: testSuccessfulPaymentMethod.code,
  83. handler: { code: testSuccessfulPaymentMethod.code, arguments: [] },
  84. },
  85. {
  86. name: twoStagePaymentMethod.code,
  87. handler: { code: twoStagePaymentMethod.code, arguments: [] },
  88. },
  89. ],
  90. },
  91. productsCsvPath: path.join(__dirname, 'fixtures/e2e-products-stock-control.csv'),
  92. customerCount: 3,
  93. });
  94. await adminClient.asSuperAdmin();
  95. await adminClient.query<UpdateGlobalSettings.Mutation, UpdateGlobalSettings.Variables>(
  96. UPDATE_GLOBAL_SETTINGS,
  97. {
  98. input: {
  99. trackInventory: false,
  100. },
  101. },
  102. );
  103. }, TEST_SETUP_TIMEOUT_MS);
  104. afterAll(async () => {
  105. await server.destroy();
  106. });
  107. describe('stock adjustments', () => {
  108. let variants: VariantWithStockFragment[];
  109. it('stockMovements are initially empty', async () => {
  110. const { product } = await adminClient.query<GetStockMovement.Query, GetStockMovement.Variables>(
  111. GET_STOCK_MOVEMENT,
  112. { id: 'T_1' },
  113. );
  114. variants = product!.variants;
  115. for (const variant of variants) {
  116. expect(variant.stockMovements.items).toEqual([]);
  117. expect(variant.stockMovements.totalItems).toEqual(0);
  118. }
  119. });
  120. it('updating ProductVariant with same stockOnHand does not create a StockMovement', async () => {
  121. const { updateProductVariants } = await adminClient.query<
  122. UpdateStock.Mutation,
  123. UpdateStock.Variables
  124. >(UPDATE_STOCK_ON_HAND, {
  125. input: [
  126. {
  127. id: variants[0].id,
  128. stockOnHand: variants[0].stockOnHand,
  129. },
  130. ] as UpdateProductVariantInput[],
  131. });
  132. expect(updateProductVariants[0]!.stockMovements.items).toEqual([]);
  133. expect(updateProductVariants[0]!.stockMovements.totalItems).toEqual(0);
  134. });
  135. it('increasing stockOnHand creates a StockMovement with correct quantity', async () => {
  136. const { updateProductVariants } = await adminClient.query<
  137. UpdateStock.Mutation,
  138. UpdateStock.Variables
  139. >(UPDATE_STOCK_ON_HAND, {
  140. input: [
  141. {
  142. id: variants[0].id,
  143. stockOnHand: variants[0].stockOnHand + 5,
  144. },
  145. ] as UpdateProductVariantInput[],
  146. });
  147. expect(updateProductVariants[0]!.stockOnHand).toBe(5);
  148. expect(updateProductVariants[0]!.stockMovements.totalItems).toEqual(1);
  149. expect(updateProductVariants[0]!.stockMovements.items[0].type).toBe(StockMovementType.ADJUSTMENT);
  150. expect(updateProductVariants[0]!.stockMovements.items[0].quantity).toBe(5);
  151. });
  152. it('decreasing stockOnHand creates a StockMovement with correct quantity', async () => {
  153. const { updateProductVariants } = await adminClient.query<
  154. UpdateStock.Mutation,
  155. UpdateStock.Variables
  156. >(UPDATE_STOCK_ON_HAND, {
  157. input: [
  158. {
  159. id: variants[0].id,
  160. stockOnHand: variants[0].stockOnHand + 5 - 2,
  161. },
  162. ] as UpdateProductVariantInput[],
  163. });
  164. expect(updateProductVariants[0]!.stockOnHand).toBe(3);
  165. expect(updateProductVariants[0]!.stockMovements.totalItems).toEqual(2);
  166. expect(updateProductVariants[0]!.stockMovements.items[1].type).toBe(StockMovementType.ADJUSTMENT);
  167. expect(updateProductVariants[0]!.stockMovements.items[1].quantity).toBe(-2);
  168. });
  169. it(
  170. 'attempting to set a negative stockOnHand throws',
  171. assertThrowsWithMessage(async () => {
  172. const result = await adminClient.query<UpdateStock.Mutation, UpdateStock.Variables>(
  173. UPDATE_STOCK_ON_HAND,
  174. {
  175. input: [
  176. {
  177. id: variants[0].id,
  178. stockOnHand: -1,
  179. },
  180. ] as UpdateProductVariantInput[],
  181. },
  182. );
  183. }, 'stockOnHand cannot be a negative value'),
  184. );
  185. });
  186. describe('sales', () => {
  187. let orderId: string;
  188. beforeAll(async () => {
  189. const product = await getProductWithStockMovement('T_2');
  190. const [variant1, variant2, variant3] = product!.variants;
  191. await adminClient.query<UpdateStock.Mutation, UpdateStock.Variables>(UPDATE_STOCK_ON_HAND, {
  192. input: [
  193. {
  194. id: variant1.id,
  195. stockOnHand: 5,
  196. trackInventory: GlobalFlag.FALSE,
  197. },
  198. {
  199. id: variant2.id,
  200. stockOnHand: 5,
  201. trackInventory: GlobalFlag.TRUE,
  202. },
  203. {
  204. id: variant3.id,
  205. stockOnHand: 5,
  206. trackInventory: GlobalFlag.INHERIT,
  207. },
  208. ] as UpdateProductVariantInput[],
  209. });
  210. // Add items to order and check out
  211. await shopClient.asUserWithCredentials('hayden.zieme12@hotmail.com', 'test');
  212. await shopClient.query<AddItemToOrder.Mutation, AddItemToOrder.Variables>(ADD_ITEM_TO_ORDER, {
  213. productVariantId: variant1.id,
  214. quantity: 2,
  215. });
  216. await shopClient.query<AddItemToOrder.Mutation, AddItemToOrder.Variables>(ADD_ITEM_TO_ORDER, {
  217. productVariantId: variant2.id,
  218. quantity: 3,
  219. });
  220. await shopClient.query<AddItemToOrder.Mutation, AddItemToOrder.Variables>(ADD_ITEM_TO_ORDER, {
  221. productVariantId: variant3.id,
  222. quantity: 4,
  223. });
  224. await shopClient.query<SetShippingAddress.Mutation, SetShippingAddress.Variables>(
  225. SET_SHIPPING_ADDRESS,
  226. {
  227. input: {
  228. streetLine1: '1 Test Street',
  229. countryCode: 'GB',
  230. } as CreateAddressInput,
  231. },
  232. );
  233. await shopClient.query<TransitionToState.Mutation, TransitionToState.Variables>(
  234. TRANSITION_TO_STATE,
  235. { state: 'ArrangingPayment' as OrderState },
  236. );
  237. });
  238. it('creates an Allocation when order completed', async () => {
  239. const { addPaymentToOrder: order } = await shopClient.query<
  240. AddPaymentToOrder.Mutation,
  241. AddPaymentToOrder.Variables
  242. >(ADD_PAYMENT, {
  243. input: {
  244. method: testSuccessfulPaymentMethod.code,
  245. metadata: {},
  246. } as PaymentInput,
  247. });
  248. orderGuard.assertSuccess(order);
  249. expect(order).not.toBeNull();
  250. orderId = order.id;
  251. const product = await getProductWithStockMovement('T_2');
  252. const [variant1, variant2, variant3] = product!.variants;
  253. expect(variant1.stockMovements.totalItems).toBe(2);
  254. expect(variant1.stockMovements.items[1].type).toBe(StockMovementType.ALLOCATION);
  255. expect(variant1.stockMovements.items[1].quantity).toBe(2);
  256. expect(variant2.stockMovements.totalItems).toBe(2);
  257. expect(variant2.stockMovements.items[1].type).toBe(StockMovementType.ALLOCATION);
  258. expect(variant2.stockMovements.items[1].quantity).toBe(3);
  259. expect(variant3.stockMovements.totalItems).toBe(2);
  260. expect(variant3.stockMovements.items[1].type).toBe(StockMovementType.ALLOCATION);
  261. expect(variant3.stockMovements.items[1].quantity).toBe(4);
  262. });
  263. it('stockAllocated is updated according to trackInventory setting', async () => {
  264. const product = await getProductWithStockMovement('T_2');
  265. const [variant1, variant2, variant3] = product!.variants;
  266. // stockOnHand not changed yet
  267. expect(variant1.stockOnHand).toBe(5);
  268. expect(variant2.stockOnHand).toBe(5);
  269. expect(variant3.stockOnHand).toBe(5);
  270. expect(variant1.stockAllocated).toBe(0); // untracked inventory
  271. expect(variant2.stockAllocated).toBe(3); // tracked inventory
  272. expect(variant3.stockAllocated).toBe(0); // inherited untracked inventory
  273. });
  274. it('creates a Release on cancelling an allocated OrderItem and updates stockAllocated', async () => {
  275. const { order } = await adminClient.query<GetOrder.Query, GetOrder.Variables>(GET_ORDER, {
  276. id: orderId,
  277. });
  278. await adminClient.query<CancelOrder.Mutation, CancelOrder.Variables>(CANCEL_ORDER, {
  279. input: {
  280. orderId: order!.id,
  281. lines: [{ orderLineId: order!.lines.find(l => l.quantity === 3)!.id, quantity: 1 }],
  282. reason: 'Not needed',
  283. },
  284. });
  285. const product = await getProductWithStockMovement('T_2');
  286. const [_, variant2, __] = product!.variants;
  287. expect(variant2.stockMovements.totalItems).toBe(3);
  288. expect(variant2.stockMovements.items[2].type).toBe(StockMovementType.RELEASE);
  289. expect(variant2.stockMovements.items[2].quantity).toBe(1);
  290. expect(variant2.stockAllocated).toBe(2);
  291. });
  292. it('creates a Sale on Fulfillment creation', async () => {
  293. const { order } = await adminClient.query<GetOrder.Query, GetOrder.Variables>(GET_ORDER, {
  294. id: orderId,
  295. });
  296. await adminClient.query<CreateFulfillment.Mutation, CreateFulfillment.Variables>(
  297. CREATE_FULFILLMENT,
  298. {
  299. input: {
  300. lines: order?.lines.map(l => ({ orderLineId: l.id, quantity: l.quantity })) ?? [],
  301. handler: {
  302. code: manualFulfillmentHandler.code,
  303. arguments: [
  304. { name: 'method', value: 'test method' },
  305. { name: 'trackingCode', value: 'ABC123' },
  306. ],
  307. },
  308. },
  309. },
  310. );
  311. const product = await getProductWithStockMovement('T_2');
  312. const [variant1, variant2, variant3] = product!.variants;
  313. expect(variant1.stockMovements.totalItems).toBe(3);
  314. expect(variant1.stockMovements.items[2].type).toBe(StockMovementType.SALE);
  315. expect(variant1.stockMovements.items[2].quantity).toBe(-2);
  316. // 4 rather than 3 since a Release was created in the previous test
  317. expect(variant2.stockMovements.totalItems).toBe(4);
  318. expect(variant2.stockMovements.items[3].type).toBe(StockMovementType.SALE);
  319. expect(variant2.stockMovements.items[3].quantity).toBe(-2);
  320. expect(variant3.stockMovements.totalItems).toBe(3);
  321. expect(variant3.stockMovements.items[2].type).toBe(StockMovementType.SALE);
  322. expect(variant3.stockMovements.items[2].quantity).toBe(-4);
  323. });
  324. it('updates stockOnHand and stockAllocated when Sales are created', async () => {
  325. const product = await getProductWithStockMovement('T_2');
  326. const [variant1, variant2, variant3] = product!.variants;
  327. expect(variant1.stockOnHand).toBe(5); // untracked inventory
  328. expect(variant2.stockOnHand).toBe(3); // tracked inventory
  329. expect(variant3.stockOnHand).toBe(5); // inherited untracked inventory
  330. expect(variant1.stockAllocated).toBe(0); // untracked inventory
  331. expect(variant2.stockAllocated).toBe(0); // tracked inventory
  332. expect(variant3.stockAllocated).toBe(0); // inherited untracked inventory
  333. });
  334. it('creates Cancellations when cancelling items which are part of a Fulfillment', async () => {
  335. const { order } = await adminClient.query<GetOrder.Query, GetOrder.Variables>(GET_ORDER, {
  336. id: orderId,
  337. });
  338. await adminClient.query<CancelOrder.Mutation, CancelOrder.Variables>(CANCEL_ORDER, {
  339. input: {
  340. orderId: order!.id,
  341. lines: order!.lines.map(l => ({ orderLineId: l.id, quantity: l.quantity })),
  342. reason: 'Faulty',
  343. },
  344. });
  345. const product = await getProductWithStockMovement('T_2');
  346. const [variant1, variant2, variant3] = product!.variants;
  347. expect(variant1.stockMovements.totalItems).toBe(5);
  348. expect(variant1.stockMovements.items[3].type).toBe(StockMovementType.CANCELLATION);
  349. expect(variant1.stockMovements.items[4].type).toBe(StockMovementType.CANCELLATION);
  350. expect(variant2.stockMovements.totalItems).toBe(6);
  351. expect(variant2.stockMovements.items[4].type).toBe(StockMovementType.CANCELLATION);
  352. expect(variant2.stockMovements.items[5].type).toBe(StockMovementType.CANCELLATION);
  353. expect(variant3.stockMovements.totalItems).toBe(7);
  354. expect(variant3.stockMovements.items[3].type).toBe(StockMovementType.CANCELLATION);
  355. expect(variant3.stockMovements.items[4].type).toBe(StockMovementType.CANCELLATION);
  356. expect(variant3.stockMovements.items[5].type).toBe(StockMovementType.CANCELLATION);
  357. expect(variant3.stockMovements.items[6].type).toBe(StockMovementType.CANCELLATION);
  358. });
  359. });
  360. describe('saleable stock level', () => {
  361. let order: TestOrderWithPaymentsFragment;
  362. beforeAll(async () => {
  363. await adminClient.query<UpdateGlobalSettings.Mutation, UpdateGlobalSettings.Variables>(
  364. UPDATE_GLOBAL_SETTINGS,
  365. {
  366. input: {
  367. trackInventory: true,
  368. outOfStockThreshold: -5,
  369. },
  370. },
  371. );
  372. await adminClient.query<UpdateProductVariants.Mutation, UpdateProductVariants.Variables>(
  373. UPDATE_PRODUCT_VARIANTS,
  374. {
  375. input: [
  376. {
  377. id: 'T_1',
  378. stockOnHand: 3,
  379. outOfStockThreshold: 0,
  380. trackInventory: GlobalFlag.TRUE,
  381. useGlobalOutOfStockThreshold: false,
  382. },
  383. {
  384. id: 'T_2',
  385. stockOnHand: 3,
  386. outOfStockThreshold: 0,
  387. trackInventory: GlobalFlag.FALSE,
  388. useGlobalOutOfStockThreshold: false,
  389. },
  390. {
  391. id: 'T_3',
  392. stockOnHand: 3,
  393. outOfStockThreshold: 2,
  394. trackInventory: GlobalFlag.TRUE,
  395. useGlobalOutOfStockThreshold: false,
  396. },
  397. {
  398. id: 'T_4',
  399. stockOnHand: 3,
  400. outOfStockThreshold: 0,
  401. trackInventory: GlobalFlag.TRUE,
  402. useGlobalOutOfStockThreshold: true,
  403. },
  404. {
  405. id: 'T_5',
  406. stockOnHand: 0,
  407. outOfStockThreshold: 0,
  408. trackInventory: GlobalFlag.TRUE,
  409. useGlobalOutOfStockThreshold: false,
  410. },
  411. ],
  412. },
  413. );
  414. await shopClient.asUserWithCredentials('trevor_donnelly96@hotmail.com', 'test');
  415. });
  416. it('does not add an empty OrderLine if zero saleable stock', async () => {
  417. const variantId = 'T_5';
  418. const { addItemToOrder } = await shopClient.query<
  419. AddItemToOrder.Mutation,
  420. AddItemToOrder.Variables
  421. >(ADD_ITEM_TO_ORDER, {
  422. productVariantId: variantId,
  423. quantity: 1,
  424. });
  425. orderGuard.assertErrorResult(addItemToOrder);
  426. expect(addItemToOrder.errorCode).toBe(ErrorCode.INSUFFICIENT_STOCK_ERROR);
  427. expect(addItemToOrder.message).toBe(`No items were added to the order due to insufficient stock`);
  428. expect((addItemToOrder as any).quantityAvailable).toBe(0);
  429. expect((addItemToOrder as any).order.lines.length).toBe(0);
  430. });
  431. it('returns InsufficientStockError when tracking inventory', async () => {
  432. const variantId = 'T_1';
  433. const { addItemToOrder } = await shopClient.query<
  434. AddItemToOrder.Mutation,
  435. AddItemToOrder.Variables
  436. >(ADD_ITEM_TO_ORDER, {
  437. productVariantId: variantId,
  438. quantity: 5,
  439. });
  440. orderGuard.assertErrorResult(addItemToOrder);
  441. expect(addItemToOrder.errorCode).toBe(ErrorCode.INSUFFICIENT_STOCK_ERROR);
  442. expect(addItemToOrder.message).toBe(
  443. `Only 3 items were added to the order due to insufficient stock`,
  444. );
  445. expect((addItemToOrder as any).quantityAvailable).toBe(3);
  446. // Still adds as many as available to the Order
  447. expect((addItemToOrder as any).order.lines[0].productVariant.id).toBe(variantId);
  448. expect((addItemToOrder as any).order.lines[0].quantity).toBe(3);
  449. const product = await getProductWithStockMovement('T_1');
  450. const variant = product!.variants[0];
  451. expect(variant.id).toBe(variantId);
  452. expect(variant.stockAllocated).toBe(0);
  453. expect(variant.stockOnHand).toBe(3);
  454. });
  455. it('does not return error when not tracking inventory', async () => {
  456. const variantId = 'T_2';
  457. const { addItemToOrder } = await shopClient.query<
  458. AddItemToOrder.Mutation,
  459. AddItemToOrder.Variables
  460. >(ADD_ITEM_TO_ORDER, {
  461. productVariantId: variantId,
  462. quantity: 5,
  463. });
  464. orderGuard.assertSuccess(addItemToOrder);
  465. expect(addItemToOrder.lines.length).toBe(2);
  466. expect(addItemToOrder.lines[1].productVariant.id).toBe(variantId);
  467. expect(addItemToOrder.lines[1].quantity).toBe(5);
  468. const product = await getProductWithStockMovement('T_1');
  469. const variant = product!.variants[1];
  470. expect(variant.id).toBe(variantId);
  471. expect(variant.stockAllocated).toBe(0);
  472. expect(variant.stockOnHand).toBe(3);
  473. });
  474. it('returns InsufficientStockError for positive threshold', async () => {
  475. const variantId = 'T_3';
  476. const { addItemToOrder } = await shopClient.query<
  477. AddItemToOrder.Mutation,
  478. AddItemToOrder.Variables
  479. >(ADD_ITEM_TO_ORDER, {
  480. productVariantId: variantId,
  481. quantity: 2,
  482. });
  483. orderGuard.assertErrorResult(addItemToOrder);
  484. expect(addItemToOrder.errorCode).toBe(ErrorCode.INSUFFICIENT_STOCK_ERROR);
  485. expect(addItemToOrder.message).toBe(
  486. `Only 1 item was added to the order due to insufficient stock`,
  487. );
  488. expect((addItemToOrder as any).quantityAvailable).toBe(1);
  489. // Still adds as many as available to the Order
  490. expect((addItemToOrder as any).order.lines.length).toBe(3);
  491. expect((addItemToOrder as any).order.lines[2].productVariant.id).toBe(variantId);
  492. expect((addItemToOrder as any).order.lines[2].quantity).toBe(1);
  493. const product = await getProductWithStockMovement('T_1');
  494. const variant = product!.variants[2];
  495. expect(variant.id).toBe(variantId);
  496. expect(variant.stockAllocated).toBe(0);
  497. expect(variant.stockOnHand).toBe(3);
  498. });
  499. it('negative threshold allows backorder', async () => {
  500. const variantId = 'T_4';
  501. const { addItemToOrder } = await shopClient.query<
  502. AddItemToOrder.Mutation,
  503. AddItemToOrder.Variables
  504. >(ADD_ITEM_TO_ORDER, {
  505. productVariantId: variantId,
  506. quantity: 8,
  507. });
  508. orderGuard.assertSuccess(addItemToOrder);
  509. expect(addItemToOrder.lines.length).toBe(4);
  510. expect(addItemToOrder.lines[3].productVariant.id).toBe(variantId);
  511. expect(addItemToOrder.lines[3].quantity).toBe(8);
  512. const product = await getProductWithStockMovement('T_1');
  513. const variant = product!.variants[3];
  514. expect(variant.id).toBe(variantId);
  515. expect(variant.stockAllocated).toBe(0);
  516. expect(variant.stockOnHand).toBe(3);
  517. });
  518. it('allocates stock', async () => {
  519. await proceedToArrangingPayment(shopClient);
  520. const result = await addPaymentToOrder(shopClient, twoStagePaymentMethod);
  521. orderGuard.assertSuccess(result);
  522. order = result;
  523. const product = await getProductWithStockMovement('T_1');
  524. const [variant1, variant2, variant3, variant4] = product!.variants;
  525. expect(variant1.stockAllocated).toBe(3);
  526. expect(variant1.stockOnHand).toBe(3);
  527. expect(variant2.stockAllocated).toBe(0); // inventory not tracked
  528. expect(variant2.stockOnHand).toBe(3);
  529. expect(variant3.stockAllocated).toBe(1);
  530. expect(variant3.stockOnHand).toBe(3);
  531. expect(variant4.stockAllocated).toBe(8);
  532. expect(variant4.stockOnHand).toBe(3);
  533. });
  534. it('does not re-allocate stock when transitioning Payment from Authorized -> Settled', async () => {
  535. await adminClient.query<SettlePayment.Mutation, SettlePayment.Variables>(SETTLE_PAYMENT, {
  536. id: order.id,
  537. });
  538. const product = await getProductWithStockMovement('T_1');
  539. const [variant1, variant2, variant3, variant4] = product!.variants;
  540. expect(variant1.stockAllocated).toBe(3);
  541. expect(variant1.stockOnHand).toBe(3);
  542. expect(variant2.stockAllocated).toBe(0); // inventory not tracked
  543. expect(variant2.stockOnHand).toBe(3);
  544. expect(variant3.stockAllocated).toBe(1);
  545. expect(variant3.stockOnHand).toBe(3);
  546. expect(variant4.stockAllocated).toBe(8);
  547. expect(variant4.stockOnHand).toBe(3);
  548. });
  549. it('addFulfillmentToOrder returns ErrorResult when insufficient stock on hand', async () => {
  550. const { addFulfillmentToOrder } = await adminClient.query<
  551. CreateFulfillment.Mutation,
  552. CreateFulfillment.Variables
  553. >(CREATE_FULFILLMENT, {
  554. input: {
  555. lines: order.lines.map(l => ({ orderLineId: l.id, quantity: l.quantity })),
  556. handler: {
  557. code: manualFulfillmentHandler.code,
  558. arguments: [
  559. { name: 'method', value: 'test method' },
  560. { name: 'trackingCode', value: 'ABC123' },
  561. ],
  562. },
  563. },
  564. });
  565. fulfillmentGuard.assertErrorResult(addFulfillmentToOrder);
  566. expect(addFulfillmentToOrder.errorCode).toBe(AdminErrorCode.INSUFFICIENT_STOCK_ON_HAND_ERROR);
  567. expect(addFulfillmentToOrder.message).toBe(
  568. `Cannot create a Fulfillment as 'Laptop 15 inch 16GB' has insufficient stockOnHand (3)`,
  569. );
  570. });
  571. it('addFulfillmentToOrder succeeds when there is sufficient stockOnHand', async () => {
  572. const { addFulfillmentToOrder } = await adminClient.query<
  573. CreateFulfillment.Mutation,
  574. CreateFulfillment.Variables
  575. >(CREATE_FULFILLMENT, {
  576. input: {
  577. lines: order.lines
  578. .filter(l => l.productVariant.id === 'T_1')
  579. .map(l => ({ orderLineId: l.id, quantity: l.quantity })),
  580. handler: {
  581. code: manualFulfillmentHandler.code,
  582. arguments: [
  583. { name: 'method', value: 'test method' },
  584. { name: 'trackingCode', value: 'ABC123' },
  585. ],
  586. },
  587. },
  588. });
  589. fulfillmentGuard.assertSuccess(addFulfillmentToOrder);
  590. const product = await getProductWithStockMovement('T_1');
  591. const variant = product!.variants[0];
  592. expect(variant.stockOnHand).toBe(0);
  593. expect(variant.stockAllocated).toBe(0);
  594. });
  595. it('addFulfillmentToOrder succeeds when inventory is not being tracked', async () => {
  596. const { addFulfillmentToOrder } = await adminClient.query<
  597. CreateFulfillment.Mutation,
  598. CreateFulfillment.Variables
  599. >(CREATE_FULFILLMENT, {
  600. input: {
  601. lines: order.lines
  602. .filter(l => l.productVariant.id === 'T_2')
  603. .map(l => ({ orderLineId: l.id, quantity: l.quantity })),
  604. handler: {
  605. code: manualFulfillmentHandler.code,
  606. arguments: [
  607. { name: 'method', value: 'test method' },
  608. { name: 'trackingCode', value: 'ABC123' },
  609. ],
  610. },
  611. },
  612. });
  613. fulfillmentGuard.assertSuccess(addFulfillmentToOrder);
  614. const product = await getProductWithStockMovement('T_1');
  615. const variant = product!.variants[1];
  616. expect(variant.stockOnHand).toBe(3);
  617. expect(variant.stockAllocated).toBe(0);
  618. });
  619. it('addFulfillmentToOrder succeeds when making a partial Fulfillment with quantity equal to stockOnHand', async () => {
  620. const { addFulfillmentToOrder } = await adminClient.query<
  621. CreateFulfillment.Mutation,
  622. CreateFulfillment.Variables
  623. >(CREATE_FULFILLMENT, {
  624. input: {
  625. lines: order.lines
  626. .filter(l => l.productVariant.id === 'T_4')
  627. .map(l => ({ orderLineId: l.id, quantity: 3 })), // we know there are only 3 on hand
  628. handler: {
  629. code: manualFulfillmentHandler.code,
  630. arguments: [
  631. { name: 'method', value: 'test method' },
  632. { name: 'trackingCode', value: 'ABC123' },
  633. ],
  634. },
  635. },
  636. });
  637. fulfillmentGuard.assertSuccess(addFulfillmentToOrder);
  638. const product = await getProductWithStockMovement('T_1');
  639. const variant = product!.variants[3];
  640. expect(variant.stockOnHand).toBe(0);
  641. expect(variant.stockAllocated).toBe(5);
  642. });
  643. it('fulfillment can be created after adjusting stockOnHand to be sufficient', async () => {
  644. const { updateProductVariants } = await adminClient.query<
  645. UpdateProductVariants.Mutation,
  646. UpdateProductVariants.Variables
  647. >(UPDATE_PRODUCT_VARIANTS, {
  648. input: [
  649. {
  650. id: 'T_4',
  651. stockOnHand: 10,
  652. },
  653. ],
  654. });
  655. expect(updateProductVariants[0]!.stockOnHand).toBe(10);
  656. const { addFulfillmentToOrder } = await adminClient.query<
  657. CreateFulfillment.Mutation,
  658. CreateFulfillment.Variables
  659. >(CREATE_FULFILLMENT, {
  660. input: {
  661. lines: order.lines
  662. .filter(l => l.productVariant.id === 'T_4')
  663. .map(l => ({ orderLineId: l.id, quantity: 5 })),
  664. handler: {
  665. code: manualFulfillmentHandler.code,
  666. arguments: [
  667. { name: 'method', value: 'test method' },
  668. { name: 'trackingCode', value: 'ABC123' },
  669. ],
  670. },
  671. },
  672. });
  673. fulfillmentGuard.assertSuccess(addFulfillmentToOrder);
  674. const product = await getProductWithStockMovement('T_1');
  675. const variant = product!.variants[3];
  676. expect(variant.stockOnHand).toBe(5);
  677. expect(variant.stockAllocated).toBe(0);
  678. });
  679. describe('edge cases', () => {
  680. const variantId = 'T_5';
  681. beforeAll(async () => {
  682. // First place an order which creates a backorder (excess of allocated units)
  683. await adminClient.query<UpdateProductVariants.Mutation, UpdateProductVariants.Variables>(
  684. UPDATE_PRODUCT_VARIANTS,
  685. {
  686. input: [
  687. {
  688. id: variantId,
  689. stockOnHand: 5,
  690. outOfStockThreshold: -20,
  691. trackInventory: GlobalFlag.TRUE,
  692. useGlobalOutOfStockThreshold: false,
  693. },
  694. ],
  695. },
  696. );
  697. await shopClient.asUserWithCredentials('trevor_donnelly96@hotmail.com', 'test');
  698. const { addItemToOrder: add1 } = await shopClient.query<
  699. AddItemToOrder.Mutation,
  700. AddItemToOrder.Variables
  701. >(ADD_ITEM_TO_ORDER, {
  702. productVariantId: variantId,
  703. quantity: 25,
  704. });
  705. orderGuard.assertSuccess(add1);
  706. await proceedToArrangingPayment(shopClient);
  707. await addPaymentToOrder(shopClient, testSuccessfulPaymentMethod);
  708. });
  709. it('zero saleable stock', async () => {
  710. await shopClient.asUserWithCredentials('hayden.zieme12@hotmail.com', 'test');
  711. // The saleable stock level is now 0 (25 allocated, 5 on hand, -20 threshold)
  712. const { addItemToOrder } = await shopClient.query<
  713. AddItemToOrder.Mutation,
  714. AddItemToOrder.Variables
  715. >(ADD_ITEM_TO_ORDER, {
  716. productVariantId: variantId,
  717. quantity: 1,
  718. });
  719. orderGuard.assertErrorResult(addItemToOrder);
  720. expect(addItemToOrder.errorCode).toBe(ErrorCode.INSUFFICIENT_STOCK_ERROR);
  721. expect(addItemToOrder.message).toBe(
  722. `No items were added to the order due to insufficient stock`,
  723. );
  724. });
  725. it('negative saleable stock', async () => {
  726. await adminClient.query<UpdateProductVariants.Mutation, UpdateProductVariants.Variables>(
  727. UPDATE_PRODUCT_VARIANTS,
  728. {
  729. input: [
  730. {
  731. id: variantId,
  732. outOfStockThreshold: -10,
  733. },
  734. ],
  735. },
  736. );
  737. // The saleable stock level is now -10 (25 allocated, 5 on hand, -10 threshold)
  738. await shopClient.asUserWithCredentials('marques.sawayn@hotmail.com', 'test');
  739. const { addItemToOrder } = await shopClient.query<
  740. AddItemToOrder.Mutation,
  741. AddItemToOrder.Variables
  742. >(ADD_ITEM_TO_ORDER, {
  743. productVariantId: variantId,
  744. quantity: 1,
  745. });
  746. orderGuard.assertErrorResult(addItemToOrder);
  747. expect(addItemToOrder.errorCode).toBe(ErrorCode.INSUFFICIENT_STOCK_ERROR);
  748. expect(addItemToOrder.message).toBe(
  749. `No items were added to the order due to insufficient stock`,
  750. );
  751. });
  752. });
  753. });
  754. });
  755. const UPDATE_STOCK_ON_HAND = gql`
  756. mutation UpdateStock($input: [UpdateProductVariantInput!]!) {
  757. updateProductVariants(input: $input) {
  758. ...VariantWithStock
  759. }
  760. }
  761. ${VARIANT_WITH_STOCK_FRAGMENT}
  762. `;