1
0

stock-control.e2e-spec.ts 65 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934935936937938939940941942943944945946947948949950951952953954955956957958959960961962963964965966967968969970971972973974975976977978979980981982983984985986987988989990991992993994995996997998999100010011002100310041005100610071008100910101011101210131014101510161017101810191020102110221023102410251026102710281029103010311032103310341035103610371038103910401041104210431044104510461047104810491050105110521053105410551056105710581059106010611062106310641065106610671068106910701071107210731074107510761077107810791080108110821083108410851086108710881089109010911092109310941095109610971098109911001101110211031104110511061107110811091110111111121113111411151116111711181119112011211122112311241125112611271128112911301131113211331134113511361137113811391140114111421143114411451146114711481149115011511152115311541155115611571158115911601161116211631164116511661167116811691170117111721173117411751176117711781179118011811182118311841185118611871188118911901191119211931194119511961197119811991200120112021203120412051206120712081209121012111212121312141215121612171218121912201221122212231224122512261227122812291230123112321233123412351236123712381239124012411242124312441245124612471248124912501251125212531254125512561257125812591260126112621263126412651266126712681269127012711272127312741275127612771278127912801281128212831284128512861287128812891290129112921293129412951296129712981299130013011302130313041305130613071308130913101311131213131314131513161317131813191320132113221323132413251326132713281329133013311332133313341335133613371338133913401341134213431344134513461347134813491350135113521353135413551356135713581359136013611362136313641365136613671368136913701371137213731374137513761377137813791380138113821383138413851386138713881389139013911392139313941395139613971398139914001401140214031404140514061407140814091410141114121413141414151416141714181419142014211422142314241425142614271428142914301431143214331434143514361437143814391440144114421443144414451446144714481449145014511452145314541455145614571458145914601461146214631464146514661467146814691470147114721473147414751476147714781479148014811482148314841485148614871488148914901491149214931494149514961497149814991500150115021503150415051506150715081509151015111512151315141515151615171518151915201521152215231524152515261527152815291530153115321533153415351536153715381539154015411542154315441545154615471548154915501551155215531554155515561557155815591560156115621563156415651566156715681569157015711572157315741575157615771578157915801581
  1. /* eslint-disable @typescript-eslint/no-non-null-assertion */
  2. import { pick } from '@vendure/common/lib/pick';
  3. import {
  4. DefaultOrderPlacedStrategy,
  5. manualFulfillmentHandler,
  6. mergeConfig,
  7. Order,
  8. OrderState,
  9. RequestContext,
  10. } from '@vendure/core';
  11. import { createErrorResultGuard, createTestEnvironment, ErrorResultGuard } from '@vendure/testing';
  12. import gql from 'graphql-tag';
  13. import path from 'path';
  14. import { afterAll, beforeAll, describe, expect, it } from 'vitest';
  15. import { initialData } from '../../../e2e-common/e2e-initial-data';
  16. import { TEST_SETUP_TIMEOUT_MS, testConfig } from '../../../e2e-common/test-config';
  17. import { testSuccessfulPaymentMethod, twoStagePaymentMethod } from './fixtures/test-payment-methods';
  18. import { VARIANT_WITH_STOCK_FRAGMENT } from './graphql/fragments';
  19. import * as Codegen from './graphql/generated-e2e-admin-types';
  20. import {
  21. ErrorCode as AdminErrorCode,
  22. CreateAddressInput,
  23. FulfillmentFragment,
  24. GlobalFlag,
  25. StockMovementType,
  26. UpdateProductVariantInput,
  27. VariantWithStockFragment,
  28. } from './graphql/generated-e2e-admin-types';
  29. import * as CodegenShop from './graphql/generated-e2e-shop-types';
  30. import { ErrorCode, PaymentInput } from './graphql/generated-e2e-shop-types';
  31. import {
  32. CANCEL_ORDER,
  33. CREATE_FULFILLMENT,
  34. GET_ORDER,
  35. GET_STOCK_MOVEMENT,
  36. GET_STOCK_MOVEMENT_BY_TYPE,
  37. SETTLE_PAYMENT,
  38. UPDATE_GLOBAL_SETTINGS,
  39. UPDATE_PRODUCT_VARIANTS,
  40. } from './graphql/shared-definitions';
  41. import {
  42. ADD_ITEM_TO_ORDER,
  43. ADD_PAYMENT,
  44. ADJUST_ITEM_QUANTITY,
  45. GET_ACTIVE_ORDER,
  46. GET_ELIGIBLE_SHIPPING_METHODS,
  47. GET_PRODUCT_WITH_STOCK_LEVEL,
  48. SET_SHIPPING_ADDRESS,
  49. SET_SHIPPING_METHOD,
  50. TRANSITION_TO_STATE,
  51. } from './graphql/shop-definitions';
  52. import { assertThrowsWithMessage } from './utils/assert-throws-with-message';
  53. import { addPaymentToOrder, proceedToArrangingPayment } from './utils/test-order-utils';
  54. class TestOrderPlacedStrategy extends DefaultOrderPlacedStrategy {
  55. shouldSetAsPlaced(
  56. ctx: RequestContext,
  57. fromState: OrderState,
  58. toState: OrderState,
  59. order: Order,
  60. ): boolean {
  61. if ((order.customFields as any).test1557) {
  62. // This branch is used in testing https://github.com/vendurehq/vendure/issues/1557
  63. // i.e. it will cause the Order to be set to `active: false` but without creating any
  64. // Allocations for the OrderLines.
  65. if (fromState === 'AddingItems' && toState === 'ArrangingPayment') {
  66. return true;
  67. }
  68. return false;
  69. }
  70. return super.shouldSetAsPlaced(ctx, fromState, toState, order);
  71. }
  72. }
  73. describe('Stock control', () => {
  74. const { server, adminClient, shopClient } = createTestEnvironment(
  75. mergeConfig(testConfig(), {
  76. paymentOptions: {
  77. paymentMethodHandlers: [testSuccessfulPaymentMethod, twoStagePaymentMethod],
  78. },
  79. orderOptions: {
  80. orderPlacedStrategy: new TestOrderPlacedStrategy(),
  81. },
  82. customFields: {
  83. Order: [
  84. {
  85. name: 'test1557',
  86. type: 'boolean',
  87. defaultValue: false,
  88. },
  89. ],
  90. OrderLine: [{ name: 'customization', type: 'string', nullable: true }],
  91. },
  92. }),
  93. );
  94. const orderGuard: ErrorResultGuard<
  95. CodegenShop.TestOrderFragmentFragment | CodegenShop.UpdatedOrderFragment
  96. > = createErrorResultGuard(input => !!input.lines);
  97. const fulfillmentGuard: ErrorResultGuard<FulfillmentFragment> = createErrorResultGuard(
  98. input => !!input.state,
  99. );
  100. async function getProductWithStockMovement(productId: string) {
  101. const { product } = await adminClient.query<
  102. Codegen.GetStockMovementQuery,
  103. Codegen.GetStockMovementQueryVariables
  104. >(GET_STOCK_MOVEMENT, { id: productId });
  105. return product;
  106. }
  107. async function getProductWithStockMovementByType(productId: string, type: StockMovementType) {
  108. const { product } = await adminClient.query<
  109. Codegen.GetStockMovementByTypeQuery,
  110. Codegen.GetStockMovementByTypeQueryVariables
  111. >(GET_STOCK_MOVEMENT_BY_TYPE, { id: productId, type });
  112. return product;
  113. }
  114. async function setFirstEligibleShippingMethod() {
  115. const { eligibleShippingMethods } = await shopClient.query<CodegenShop.GetShippingMethodsQuery>(
  116. GET_ELIGIBLE_SHIPPING_METHODS,
  117. );
  118. await shopClient.query<
  119. CodegenShop.SetShippingMethodMutation,
  120. CodegenShop.SetShippingMethodMutationVariables
  121. >(SET_SHIPPING_METHOD, {
  122. id: eligibleShippingMethods[0].id,
  123. });
  124. }
  125. beforeAll(async () => {
  126. await server.init({
  127. initialData: {
  128. ...initialData,
  129. paymentMethods: [
  130. {
  131. name: testSuccessfulPaymentMethod.code,
  132. handler: { code: testSuccessfulPaymentMethod.code, arguments: [] },
  133. },
  134. {
  135. name: twoStagePaymentMethod.code,
  136. handler: { code: twoStagePaymentMethod.code, arguments: [] },
  137. },
  138. ],
  139. },
  140. productsCsvPath: path.join(__dirname, 'fixtures/e2e-products-stock-control.csv'),
  141. customerCount: 3,
  142. });
  143. await adminClient.asSuperAdmin();
  144. await adminClient.query<
  145. Codegen.UpdateGlobalSettingsMutation,
  146. Codegen.UpdateGlobalSettingsMutationVariables
  147. >(UPDATE_GLOBAL_SETTINGS, {
  148. input: {
  149. trackInventory: false,
  150. },
  151. });
  152. }, TEST_SETUP_TIMEOUT_MS);
  153. afterAll(async () => {
  154. await server.destroy();
  155. });
  156. describe('stock adjustments', () => {
  157. let variants: VariantWithStockFragment[];
  158. it('stockMovements are initially empty', async () => {
  159. const { product } = await adminClient.query<
  160. Codegen.GetStockMovementQuery,
  161. Codegen.GetStockMovementQueryVariables
  162. >(GET_STOCK_MOVEMENT, { id: 'T_1' });
  163. variants = product!.variants;
  164. for (const variant of variants) {
  165. expect(variant.stockMovements.items).toEqual([]);
  166. expect(variant.stockMovements.totalItems).toEqual(0);
  167. }
  168. });
  169. it('updating ProductVariant with same stockOnHand does not create a StockMovement', async () => {
  170. const { updateProductVariants } = await adminClient.query<
  171. Codegen.UpdateStockMutation,
  172. Codegen.UpdateStockMutationVariables
  173. >(UPDATE_STOCK_ON_HAND, {
  174. input: [
  175. {
  176. id: variants[0].id,
  177. stockOnHand: variants[0].stockOnHand,
  178. },
  179. ] as UpdateProductVariantInput[],
  180. });
  181. expect(updateProductVariants[0]!.stockMovements.items).toEqual([]);
  182. expect(updateProductVariants[0]!.stockMovements.totalItems).toEqual(0);
  183. });
  184. it('increasing stockOnHand creates a StockMovement with correct quantity', async () => {
  185. const { updateProductVariants } = await adminClient.query<
  186. Codegen.UpdateStockMutation,
  187. Codegen.UpdateStockMutationVariables
  188. >(UPDATE_STOCK_ON_HAND, {
  189. input: [
  190. {
  191. id: variants[0].id,
  192. stockOnHand: variants[0].stockOnHand + 5,
  193. },
  194. ] as UpdateProductVariantInput[],
  195. });
  196. expect(updateProductVariants[0]!.stockOnHand).toBe(5);
  197. expect(updateProductVariants[0]!.stockMovements.totalItems).toEqual(1);
  198. expect(updateProductVariants[0]!.stockMovements.items[0].type).toBe(StockMovementType.ADJUSTMENT);
  199. expect(updateProductVariants[0]!.stockMovements.items[0].quantity).toBe(5);
  200. });
  201. it('decreasing stockOnHand creates a StockMovement with correct quantity', async () => {
  202. const { updateProductVariants } = await adminClient.query<
  203. Codegen.UpdateStockMutation,
  204. Codegen.UpdateStockMutationVariables
  205. >(UPDATE_STOCK_ON_HAND, {
  206. input: [
  207. {
  208. id: variants[0].id,
  209. stockOnHand: variants[0].stockOnHand + 5 - 2,
  210. },
  211. ] as UpdateProductVariantInput[],
  212. });
  213. expect(updateProductVariants[0]!.stockOnHand).toBe(3);
  214. expect(updateProductVariants[0]!.stockMovements.totalItems).toEqual(2);
  215. expect(updateProductVariants[0]!.stockMovements.items[1].type).toBe(StockMovementType.ADJUSTMENT);
  216. expect(updateProductVariants[0]!.stockMovements.items[1].quantity).toBe(-2);
  217. });
  218. it(
  219. 'attempting to set stockOnHand below saleable stock level throws',
  220. assertThrowsWithMessage(async () => {
  221. const result = await adminClient.query<
  222. Codegen.UpdateStockMutation,
  223. Codegen.UpdateStockMutationVariables
  224. >(UPDATE_STOCK_ON_HAND, {
  225. input: [
  226. {
  227. id: variants[0].id,
  228. stockOnHand: -1,
  229. },
  230. ] as UpdateProductVariantInput[],
  231. });
  232. }, 'stockOnHand cannot be a negative value'),
  233. );
  234. });
  235. describe('sales', () => {
  236. let orderId: string;
  237. beforeAll(async () => {
  238. const product = await getProductWithStockMovement('T_2');
  239. const [variant1, variant2, variant3] = product!.variants;
  240. await adminClient.query<Codegen.UpdateStockMutation, Codegen.UpdateStockMutationVariables>(
  241. UPDATE_STOCK_ON_HAND,
  242. {
  243. input: [
  244. {
  245. id: variant1.id,
  246. stockOnHand: 5,
  247. trackInventory: GlobalFlag.FALSE,
  248. },
  249. {
  250. id: variant2.id,
  251. stockOnHand: 5,
  252. trackInventory: GlobalFlag.TRUE,
  253. },
  254. {
  255. id: variant3.id,
  256. stockOnHand: 5,
  257. trackInventory: GlobalFlag.INHERIT,
  258. },
  259. ] as UpdateProductVariantInput[],
  260. },
  261. );
  262. // Add items to order and check out
  263. await shopClient.asUserWithCredentials('hayden.zieme12@hotmail.com', 'test');
  264. await shopClient.query<
  265. CodegenShop.AddItemToOrderMutation,
  266. CodegenShop.AddItemToOrderMutationVariables
  267. >(ADD_ITEM_TO_ORDER, {
  268. productVariantId: variant1.id,
  269. quantity: 2,
  270. });
  271. await shopClient.query<
  272. CodegenShop.AddItemToOrderMutation,
  273. CodegenShop.AddItemToOrderMutationVariables
  274. >(ADD_ITEM_TO_ORDER, {
  275. productVariantId: variant2.id,
  276. quantity: 3,
  277. });
  278. await shopClient.query<
  279. CodegenShop.AddItemToOrderMutation,
  280. CodegenShop.AddItemToOrderMutationVariables
  281. >(ADD_ITEM_TO_ORDER, {
  282. productVariantId: variant3.id,
  283. quantity: 4,
  284. });
  285. await shopClient.query<
  286. CodegenShop.SetShippingAddressMutation,
  287. CodegenShop.SetShippingAddressMutationVariables
  288. >(SET_SHIPPING_ADDRESS, {
  289. input: {
  290. streetLine1: '1 Test Street',
  291. countryCode: 'GB',
  292. } as CreateAddressInput,
  293. });
  294. await setFirstEligibleShippingMethod();
  295. await shopClient.query<
  296. CodegenShop.TransitionToStateMutation,
  297. CodegenShop.TransitionToStateMutationVariables
  298. >(TRANSITION_TO_STATE, { state: 'ArrangingPayment' as OrderState });
  299. });
  300. it('creates an Allocation when order completed', async () => {
  301. const { addPaymentToOrder: order } = await shopClient.query<
  302. CodegenShop.AddPaymentToOrderMutation,
  303. CodegenShop.AddPaymentToOrderMutationVariables
  304. >(ADD_PAYMENT, {
  305. input: {
  306. method: testSuccessfulPaymentMethod.code,
  307. metadata: {},
  308. } as PaymentInput,
  309. });
  310. orderGuard.assertSuccess(order);
  311. expect(order).not.toBeNull();
  312. orderId = order.id;
  313. const product = await getProductWithStockMovement('T_2');
  314. const [variant1, variant2, variant3] = product!.variants;
  315. expect(variant1.stockMovements.totalItems).toBe(2);
  316. expect(variant1.stockMovements.items[1].type).toBe(StockMovementType.ALLOCATION);
  317. expect(variant1.stockMovements.items[1].quantity).toBe(2);
  318. expect(variant2.stockMovements.totalItems).toBe(2);
  319. expect(variant2.stockMovements.items[1].type).toBe(StockMovementType.ALLOCATION);
  320. expect(variant2.stockMovements.items[1].quantity).toBe(3);
  321. expect(variant3.stockMovements.totalItems).toBe(2);
  322. expect(variant3.stockMovements.items[1].type).toBe(StockMovementType.ALLOCATION);
  323. expect(variant3.stockMovements.items[1].quantity).toBe(4);
  324. });
  325. it('returns all stockMovements filtered by type', async () => {
  326. const product = await getProductWithStockMovementByType('T_2', StockMovementType.ALLOCATION);
  327. const [variant1, variant2, variant3] = product!.variants;
  328. expect(variant1.stockMovements.totalItems).toBe(1);
  329. expect(variant1.stockMovements.items[0].type).toBe(StockMovementType.ALLOCATION);
  330. expect(variant1.stockMovements.items[0].quantity).toBe(2);
  331. expect(variant2.stockMovements.totalItems).toBe(1);
  332. expect(variant2.stockMovements.items[0].type).toBe(StockMovementType.ALLOCATION);
  333. expect(variant2.stockMovements.items[0].quantity).toBe(3);
  334. expect(variant3.stockMovements.totalItems).toBe(1);
  335. expect(variant3.stockMovements.items[0].type).toBe(StockMovementType.ALLOCATION);
  336. expect(variant3.stockMovements.items[0].quantity).toBe(4);
  337. });
  338. it('stockAllocated is updated according to trackInventory setting', async () => {
  339. const product = await getProductWithStockMovement('T_2');
  340. const [variant1, variant2, variant3] = product!.variants;
  341. // stockOnHand not changed yet
  342. expect(variant1.stockOnHand).toBe(5);
  343. expect(variant2.stockOnHand).toBe(5);
  344. expect(variant3.stockOnHand).toBe(5);
  345. expect(variant1.stockAllocated).toBe(0); // untracked inventory
  346. expect(variant2.stockAllocated).toBe(3); // tracked inventory
  347. expect(variant3.stockAllocated).toBe(0); // inherited untracked inventory
  348. });
  349. it('creates a Release on cancelling an allocated order item and updates stockAllocated', async () => {
  350. const { order } = await adminClient.query<Codegen.GetOrderQuery, Codegen.GetOrderQueryVariables>(
  351. GET_ORDER,
  352. {
  353. id: orderId,
  354. },
  355. );
  356. await adminClient.query<Codegen.CancelOrderMutation, Codegen.CancelOrderMutationVariables>(
  357. CANCEL_ORDER,
  358. {
  359. input: {
  360. orderId: order!.id,
  361. lines: [{ orderLineId: order!.lines.find(l => l.quantity === 3)!.id, quantity: 1 }],
  362. reason: 'Not needed',
  363. },
  364. },
  365. );
  366. const product = await getProductWithStockMovement('T_2');
  367. const [_, variant2, __] = product!.variants;
  368. expect(variant2.stockMovements.totalItems).toBe(3);
  369. expect(variant2.stockMovements.items[2].type).toBe(StockMovementType.RELEASE);
  370. expect(variant2.stockMovements.items[2].quantity).toBe(1);
  371. expect(variant2.stockAllocated).toBe(2);
  372. });
  373. it('creates a Sale on Fulfillment creation', async () => {
  374. const { order } = await adminClient.query<Codegen.GetOrderQuery, Codegen.GetOrderQueryVariables>(
  375. GET_ORDER,
  376. {
  377. id: orderId,
  378. },
  379. );
  380. await adminClient.query<
  381. Codegen.CreateFulfillmentMutation,
  382. Codegen.CreateFulfillmentMutationVariables
  383. >(CREATE_FULFILLMENT, {
  384. input: {
  385. lines: order?.lines.map(l => ({ orderLineId: l.id, quantity: l.quantity })) ?? [],
  386. handler: {
  387. code: manualFulfillmentHandler.code,
  388. arguments: [
  389. { name: 'method', value: 'test method' },
  390. { name: 'trackingCode', value: 'ABC123' },
  391. ],
  392. },
  393. },
  394. });
  395. const product = await getProductWithStockMovement('T_2');
  396. const [variant1, variant2, variant3] = product!.variants;
  397. expect(variant1.stockMovements.totalItems).toBe(3);
  398. expect(variant1.stockMovements.items[2].type).toBe(StockMovementType.SALE);
  399. expect(variant1.stockMovements.items[2].quantity).toBe(-2);
  400. // 4 rather than 3 since a Release was created in the previous test
  401. expect(variant2.stockMovements.totalItems).toBe(4);
  402. expect(variant2.stockMovements.items[3].type).toBe(StockMovementType.SALE);
  403. expect(variant2.stockMovements.items[3].quantity).toBe(-2);
  404. expect(variant3.stockMovements.totalItems).toBe(3);
  405. expect(variant3.stockMovements.items[2].type).toBe(StockMovementType.SALE);
  406. expect(variant3.stockMovements.items[2].quantity).toBe(-4);
  407. });
  408. it('updates stockOnHand and stockAllocated when Sales are created', async () => {
  409. const product = await getProductWithStockMovement('T_2');
  410. const [variant1, variant2, variant3] = product!.variants;
  411. expect(variant1.stockOnHand).toBe(5); // untracked inventory
  412. expect(variant2.stockOnHand).toBe(3); // tracked inventory
  413. expect(variant3.stockOnHand).toBe(5); // inherited untracked inventory
  414. expect(variant1.stockAllocated).toBe(0); // untracked inventory
  415. expect(variant2.stockAllocated).toBe(0); // tracked inventory
  416. expect(variant3.stockAllocated).toBe(0); // inherited untracked inventory
  417. });
  418. it('creates Cancellations when cancelling items which are part of a Fulfillment', async () => {
  419. const { order } = await adminClient.query<Codegen.GetOrderQuery, Codegen.GetOrderQueryVariables>(
  420. GET_ORDER,
  421. {
  422. id: orderId,
  423. },
  424. );
  425. await adminClient.query<Codegen.CancelOrderMutation, Codegen.CancelOrderMutationVariables>(
  426. CANCEL_ORDER,
  427. {
  428. input: {
  429. orderId: order!.id,
  430. lines: order!.lines.map(l => ({ orderLineId: l.id, quantity: l.quantity })),
  431. reason: 'Faulty',
  432. },
  433. },
  434. );
  435. const product = await getProductWithStockMovement('T_2');
  436. const [variant1, variant2, variant3] = product!.variants;
  437. expect(variant1.stockMovements.totalItems).toBe(4);
  438. expect(variant1.stockMovements.items[3].type).toBe(StockMovementType.CANCELLATION);
  439. expect(variant1.stockMovements.items[3].quantity).toBe(2);
  440. expect(variant2.stockMovements.totalItems).toBe(5);
  441. expect(variant2.stockMovements.items[4].type).toBe(StockMovementType.CANCELLATION);
  442. expect(variant2.stockMovements.items[4].quantity).toBe(2);
  443. expect(variant3.stockMovements.totalItems).toBe(4);
  444. expect(variant3.stockMovements.items[3].type).toBe(StockMovementType.CANCELLATION);
  445. expect(variant3.stockMovements.items[3].quantity).toBe(4);
  446. });
  447. // https://github.com/vendurehq/vendure/issues/1198
  448. it('creates Cancellations & adjusts stock when cancelling a Fulfillment', async () => {
  449. async function getTrackedVariant() {
  450. const result = await getProductWithStockMovement('T_2');
  451. return result!.variants[1];
  452. }
  453. const trackedVariant1 = await getTrackedVariant();
  454. expect(trackedVariant1.stockOnHand).toBe(5);
  455. // Add items to order and check out
  456. await shopClient.asUserWithCredentials('hayden.zieme12@hotmail.com', 'test');
  457. await shopClient.query<
  458. CodegenShop.AddItemToOrderMutation,
  459. CodegenShop.AddItemToOrderMutationVariables
  460. >(ADD_ITEM_TO_ORDER, {
  461. productVariantId: trackedVariant1.id,
  462. quantity: 1,
  463. });
  464. await shopClient.query<
  465. CodegenShop.SetShippingAddressMutation,
  466. CodegenShop.SetShippingAddressMutationVariables
  467. >(SET_SHIPPING_ADDRESS, {
  468. input: {
  469. streetLine1: '1 Test Street',
  470. countryCode: 'GB',
  471. } as CreateAddressInput,
  472. });
  473. await setFirstEligibleShippingMethod();
  474. await shopClient.query<
  475. CodegenShop.TransitionToStateMutation,
  476. CodegenShop.TransitionToStateMutationVariables
  477. >(TRANSITION_TO_STATE, { state: 'ArrangingPayment' as OrderState });
  478. const { addPaymentToOrder: order } = await shopClient.query<
  479. CodegenShop.AddPaymentToOrderMutation,
  480. CodegenShop.AddPaymentToOrderMutationVariables
  481. >(ADD_PAYMENT, {
  482. input: {
  483. method: testSuccessfulPaymentMethod.code,
  484. metadata: {},
  485. } as PaymentInput,
  486. });
  487. orderGuard.assertSuccess(order);
  488. expect(order).not.toBeNull();
  489. const trackedVariant2 = await getTrackedVariant();
  490. expect(trackedVariant2.stockOnHand).toBe(5);
  491. expect(trackedVariant2.stockAllocated).toBe(1);
  492. const linesInput =
  493. order?.lines
  494. .filter(l => l.productVariant.id === trackedVariant2.id)
  495. .map(l => ({ orderLineId: l.id, quantity: l.quantity })) ?? [];
  496. const { addFulfillmentToOrder } = await adminClient.query<
  497. Codegen.CreateFulfillmentMutation,
  498. Codegen.CreateFulfillmentMutationVariables
  499. >(CREATE_FULFILLMENT, {
  500. input: {
  501. lines: linesInput,
  502. handler: {
  503. code: manualFulfillmentHandler.code,
  504. arguments: [
  505. { name: 'method', value: 'test method' },
  506. { name: 'trackingCode', value: 'ABC123' },
  507. ],
  508. },
  509. },
  510. });
  511. const trackedVariant3 = await getTrackedVariant();
  512. expect(trackedVariant3.stockOnHand).toBe(4);
  513. expect(trackedVariant3.stockAllocated).toBe(0);
  514. const { transitionFulfillmentToState } = await adminClient.query<
  515. Codegen.TransitionFulfillmentToStateMutation,
  516. Codegen.TransitionFulfillmentToStateMutationVariables
  517. >(TRANSITION_FULFILLMENT_TO_STATE, {
  518. state: 'Cancelled',
  519. id: (addFulfillmentToOrder as any).id,
  520. });
  521. const trackedVariant4 = await getTrackedVariant();
  522. expect(trackedVariant4.stockOnHand).toBe(5);
  523. expect(trackedVariant4.stockAllocated).toBe(1);
  524. expect(trackedVariant4.stockMovements.items.map(pick(['quantity', 'type']))).toEqual([
  525. { quantity: 5, type: 'ADJUSTMENT' },
  526. { quantity: 3, type: 'ALLOCATION' },
  527. { quantity: 1, type: 'RELEASE' },
  528. { quantity: -2, type: 'SALE' },
  529. { quantity: 2, type: 'CANCELLATION' },
  530. { quantity: 1, type: 'ALLOCATION' },
  531. { quantity: -1, type: 'SALE' },
  532. // This is the cancellation & allocation we are testing for
  533. { quantity: 1, type: 'CANCELLATION' },
  534. { quantity: 1, type: 'ALLOCATION' },
  535. ]);
  536. const { cancelOrder } = await adminClient.query<
  537. Codegen.CancelOrderMutation,
  538. Codegen.CancelOrderMutationVariables
  539. >(CANCEL_ORDER, {
  540. input: {
  541. orderId: order.id,
  542. reason: 'Not needed',
  543. },
  544. });
  545. orderGuard.assertSuccess(cancelOrder);
  546. const trackedVariant5 = await getTrackedVariant();
  547. expect(trackedVariant5.stockOnHand).toBe(5);
  548. expect(trackedVariant5.stockAllocated).toBe(0);
  549. });
  550. });
  551. describe('saleable stock level', () => {
  552. let order: CodegenShop.TestOrderWithPaymentsFragment;
  553. beforeAll(async () => {
  554. await adminClient.query<
  555. Codegen.UpdateGlobalSettingsMutation,
  556. Codegen.UpdateGlobalSettingsMutationVariables
  557. >(UPDATE_GLOBAL_SETTINGS, {
  558. input: {
  559. trackInventory: true,
  560. outOfStockThreshold: -5,
  561. },
  562. });
  563. await adminClient.query<
  564. Codegen.UpdateProductVariantsMutation,
  565. Codegen.UpdateProductVariantsMutationVariables
  566. >(UPDATE_PRODUCT_VARIANTS, {
  567. input: [
  568. {
  569. id: 'T_1',
  570. stockOnHand: 3,
  571. outOfStockThreshold: 0,
  572. trackInventory: GlobalFlag.TRUE,
  573. useGlobalOutOfStockThreshold: false,
  574. },
  575. {
  576. id: 'T_2',
  577. stockOnHand: 3,
  578. outOfStockThreshold: 0,
  579. trackInventory: GlobalFlag.FALSE,
  580. useGlobalOutOfStockThreshold: false,
  581. },
  582. {
  583. id: 'T_3',
  584. stockOnHand: 3,
  585. outOfStockThreshold: 2,
  586. trackInventory: GlobalFlag.TRUE,
  587. useGlobalOutOfStockThreshold: false,
  588. },
  589. {
  590. id: 'T_4',
  591. stockOnHand: 3,
  592. outOfStockThreshold: 0,
  593. trackInventory: GlobalFlag.TRUE,
  594. useGlobalOutOfStockThreshold: true,
  595. },
  596. {
  597. id: 'T_5',
  598. stockOnHand: 0,
  599. outOfStockThreshold: 0,
  600. trackInventory: GlobalFlag.TRUE,
  601. useGlobalOutOfStockThreshold: false,
  602. },
  603. ],
  604. });
  605. await shopClient.asUserWithCredentials('trevor_donnelly96@hotmail.com', 'test');
  606. });
  607. it('stockLevel uses DefaultStockDisplayStrategy', async () => {
  608. const { product } = await shopClient.query<
  609. CodegenShop.GetProductStockLevelQuery,
  610. CodegenShop.GetProductStockLevelQueryVariables
  611. >(GET_PRODUCT_WITH_STOCK_LEVEL, {
  612. id: 'T_2',
  613. });
  614. expect(product?.variants.map(v => v.stockLevel)).toEqual([
  615. 'OUT_OF_STOCK',
  616. 'IN_STOCK',
  617. 'IN_STOCK',
  618. ]);
  619. });
  620. it('does not add an empty OrderLine if zero saleable stock', async () => {
  621. const variantId = 'T_5';
  622. const { addItemToOrder } = await shopClient.query<
  623. CodegenShop.AddItemToOrderMutation,
  624. CodegenShop.AddItemToOrderMutationVariables
  625. >(ADD_ITEM_TO_ORDER, {
  626. productVariantId: variantId,
  627. quantity: 1,
  628. });
  629. orderGuard.assertErrorResult(addItemToOrder);
  630. expect(addItemToOrder.errorCode).toBe(ErrorCode.INSUFFICIENT_STOCK_ERROR);
  631. expect(addItemToOrder.message).toBe('No items were added to the order due to insufficient stock');
  632. expect((addItemToOrder as any).quantityAvailable).toBe(0);
  633. expect((addItemToOrder as any).order.lines.length).toBe(0);
  634. });
  635. it('returns InsufficientStockError when tracking inventory & adding too many at once', async () => {
  636. const variantId = 'T_1';
  637. const { addItemToOrder } = await shopClient.query<
  638. CodegenShop.AddItemToOrderMutation,
  639. CodegenShop.AddItemToOrderMutationVariables
  640. >(ADD_ITEM_TO_ORDER, {
  641. productVariantId: variantId,
  642. quantity: 5,
  643. });
  644. orderGuard.assertErrorResult(addItemToOrder);
  645. expect(addItemToOrder.errorCode).toBe(ErrorCode.INSUFFICIENT_STOCK_ERROR);
  646. expect(addItemToOrder.message).toBe(
  647. 'Only 3 items were added to the order due to insufficient stock',
  648. );
  649. expect((addItemToOrder as any).quantityAvailable).toBe(3);
  650. // Still adds as many as available to the Order
  651. expect((addItemToOrder as any).order.lines[0].productVariant.id).toBe(variantId);
  652. expect((addItemToOrder as any).order.lines[0].quantity).toBe(3);
  653. const product = await getProductWithStockMovement('T_1');
  654. const variant = product!.variants[0];
  655. expect(variant.id).toBe(variantId);
  656. expect(variant.stockAllocated).toBe(0);
  657. expect(variant.stockOnHand).toBe(3);
  658. });
  659. it('does not return error when not tracking inventory', async () => {
  660. const variantId = 'T_2';
  661. const { addItemToOrder } = await shopClient.query<
  662. CodegenShop.AddItemToOrderMutation,
  663. CodegenShop.AddItemToOrderMutationVariables
  664. >(ADD_ITEM_TO_ORDER, {
  665. productVariantId: variantId,
  666. quantity: 5,
  667. });
  668. orderGuard.assertSuccess(addItemToOrder);
  669. expect(addItemToOrder.lines.length).toBe(2);
  670. expect(addItemToOrder.lines[1].productVariant.id).toBe(variantId);
  671. expect(addItemToOrder.lines[1].quantity).toBe(5);
  672. const product = await getProductWithStockMovement('T_1');
  673. const variant = product!.variants[1];
  674. expect(variant.id).toBe(variantId);
  675. expect(variant.stockAllocated).toBe(0);
  676. expect(variant.stockOnHand).toBe(3);
  677. });
  678. it('returns InsufficientStockError for positive threshold', async () => {
  679. const variantId = 'T_3';
  680. const { addItemToOrder } = await shopClient.query<
  681. CodegenShop.AddItemToOrderMutation,
  682. CodegenShop.AddItemToOrderMutationVariables
  683. >(ADD_ITEM_TO_ORDER, {
  684. productVariantId: variantId,
  685. quantity: 2,
  686. });
  687. orderGuard.assertErrorResult(addItemToOrder);
  688. expect(addItemToOrder.errorCode).toBe(ErrorCode.INSUFFICIENT_STOCK_ERROR);
  689. expect(addItemToOrder.message).toBe(
  690. 'Only 1 item was added to the order due to insufficient stock',
  691. );
  692. expect((addItemToOrder as any).quantityAvailable).toBe(1);
  693. // Still adds as many as available to the Order
  694. expect((addItemToOrder as any).order.lines.length).toBe(3);
  695. expect((addItemToOrder as any).order.lines[2].productVariant.id).toBe(variantId);
  696. expect((addItemToOrder as any).order.lines[2].quantity).toBe(1);
  697. const product = await getProductWithStockMovement('T_1');
  698. const variant = product!.variants[2];
  699. expect(variant.id).toBe(variantId);
  700. expect(variant.stockAllocated).toBe(0);
  701. expect(variant.stockOnHand).toBe(3);
  702. });
  703. it('negative threshold allows backorder', async () => {
  704. const variantId = 'T_4';
  705. const { addItemToOrder } = await shopClient.query<
  706. CodegenShop.AddItemToOrderMutation,
  707. CodegenShop.AddItemToOrderMutationVariables
  708. >(ADD_ITEM_TO_ORDER, {
  709. productVariantId: variantId,
  710. quantity: 8,
  711. });
  712. orderGuard.assertSuccess(addItemToOrder);
  713. expect(addItemToOrder.lines.length).toBe(4);
  714. expect(addItemToOrder.lines[3].productVariant.id).toBe(variantId);
  715. expect(addItemToOrder.lines[3].quantity).toBe(8);
  716. const product = await getProductWithStockMovement('T_1');
  717. const variant = product!.variants[3];
  718. expect(variant.id).toBe(variantId);
  719. expect(variant.stockAllocated).toBe(0);
  720. expect(variant.stockOnHand).toBe(3);
  721. });
  722. it('allocates stock', async () => {
  723. await proceedToArrangingPayment(shopClient);
  724. const result = await addPaymentToOrder(shopClient, twoStagePaymentMethod);
  725. orderGuard.assertSuccess(result);
  726. order = result;
  727. const product = await getProductWithStockMovement('T_1');
  728. const [variant1, variant2, variant3, variant4] = product!.variants;
  729. expect(variant1.stockAllocated).toBe(3);
  730. expect(variant1.stockOnHand).toBe(3);
  731. expect(variant2.stockAllocated).toBe(0); // inventory not tracked
  732. expect(variant2.stockOnHand).toBe(3);
  733. expect(variant3.stockAllocated).toBe(1);
  734. expect(variant3.stockOnHand).toBe(3);
  735. expect(variant4.stockAllocated).toBe(8);
  736. expect(variant4.stockOnHand).toBe(3);
  737. });
  738. it('does not re-allocate stock when transitioning Payment from Authorized -> Settled', async () => {
  739. await adminClient.query<Codegen.SettlePaymentMutation, Codegen.SettlePaymentMutationVariables>(
  740. SETTLE_PAYMENT,
  741. {
  742. id: order.id,
  743. },
  744. );
  745. const product = await getProductWithStockMovement('T_1');
  746. const [variant1, variant2, variant3, variant4] = product!.variants;
  747. expect(variant1.stockAllocated).toBe(3);
  748. expect(variant1.stockOnHand).toBe(3);
  749. expect(variant2.stockAllocated).toBe(0); // inventory not tracked
  750. expect(variant2.stockOnHand).toBe(3);
  751. expect(variant3.stockAllocated).toBe(1);
  752. expect(variant3.stockOnHand).toBe(3);
  753. expect(variant4.stockAllocated).toBe(8);
  754. expect(variant4.stockOnHand).toBe(3);
  755. });
  756. it('addFulfillmentToOrder returns ErrorResult when insufficient stock on hand', async () => {
  757. const { addFulfillmentToOrder } = await adminClient.query<
  758. Codegen.CreateFulfillmentMutation,
  759. Codegen.CreateFulfillmentMutationVariables
  760. >(CREATE_FULFILLMENT, {
  761. input: {
  762. lines: order.lines.map(l => ({ orderLineId: l.id, quantity: l.quantity })),
  763. handler: {
  764. code: manualFulfillmentHandler.code,
  765. arguments: [
  766. { name: 'method', value: 'test method' },
  767. { name: 'trackingCode', value: 'ABC123' },
  768. ],
  769. },
  770. },
  771. });
  772. fulfillmentGuard.assertErrorResult(addFulfillmentToOrder);
  773. expect(addFulfillmentToOrder.errorCode).toBe(AdminErrorCode.INSUFFICIENT_STOCK_ON_HAND_ERROR);
  774. expect(addFulfillmentToOrder.message).toBe(
  775. 'Cannot create a Fulfillment as "Laptop 15 inch 16GB" has insufficient stockOnHand (3)',
  776. );
  777. });
  778. it('addFulfillmentToOrder succeeds when there is sufficient stockOnHand', async () => {
  779. const { addFulfillmentToOrder } = await adminClient.query<
  780. Codegen.CreateFulfillmentMutation,
  781. Codegen.CreateFulfillmentMutationVariables
  782. >(CREATE_FULFILLMENT, {
  783. input: {
  784. lines: order.lines
  785. .filter(l => l.productVariant.id === 'T_1')
  786. .map(l => ({ orderLineId: l.id, quantity: l.quantity })),
  787. handler: {
  788. code: manualFulfillmentHandler.code,
  789. arguments: [
  790. { name: 'method', value: 'test method' },
  791. { name: 'trackingCode', value: 'ABC123' },
  792. ],
  793. },
  794. },
  795. });
  796. fulfillmentGuard.assertSuccess(addFulfillmentToOrder);
  797. const product = await getProductWithStockMovement('T_1');
  798. const variant = product!.variants[0];
  799. expect(variant.stockOnHand).toBe(0);
  800. expect(variant.stockAllocated).toBe(0);
  801. });
  802. it('addFulfillmentToOrder succeeds when inventory is not being tracked', async () => {
  803. const { addFulfillmentToOrder } = await adminClient.query<
  804. Codegen.CreateFulfillmentMutation,
  805. Codegen.CreateFulfillmentMutationVariables
  806. >(CREATE_FULFILLMENT, {
  807. input: {
  808. lines: order.lines
  809. .filter(l => l.productVariant.id === 'T_2')
  810. .map(l => ({ orderLineId: l.id, quantity: l.quantity })),
  811. handler: {
  812. code: manualFulfillmentHandler.code,
  813. arguments: [
  814. { name: 'method', value: 'test method' },
  815. { name: 'trackingCode', value: 'ABC123' },
  816. ],
  817. },
  818. },
  819. });
  820. fulfillmentGuard.assertSuccess(addFulfillmentToOrder);
  821. const product = await getProductWithStockMovement('T_1');
  822. const variant = product!.variants[1];
  823. expect(variant.stockOnHand).toBe(3);
  824. expect(variant.stockAllocated).toBe(0);
  825. });
  826. it('addFulfillmentToOrder succeeds when making a partial Fulfillment with quantity equal to stockOnHand', async () => {
  827. const { addFulfillmentToOrder } = await adminClient.query<
  828. Codegen.CreateFulfillmentMutation,
  829. Codegen.CreateFulfillmentMutationVariables
  830. >(CREATE_FULFILLMENT, {
  831. input: {
  832. lines: order.lines
  833. .filter(l => l.productVariant.id === 'T_4')
  834. .map(l => ({ orderLineId: l.id, quantity: 3 })), // we know there are only 3 on hand
  835. handler: {
  836. code: manualFulfillmentHandler.code,
  837. arguments: [
  838. { name: 'method', value: 'test method' },
  839. { name: 'trackingCode', value: 'ABC123' },
  840. ],
  841. },
  842. },
  843. });
  844. fulfillmentGuard.assertSuccess(addFulfillmentToOrder);
  845. const product = await getProductWithStockMovement('T_1');
  846. const variant = product!.variants[3];
  847. expect(variant.stockOnHand).toBe(0);
  848. expect(variant.stockAllocated).toBe(5);
  849. });
  850. it('fulfillment can be created after adjusting stockOnHand to be sufficient', async () => {
  851. const { updateProductVariants } = await adminClient.query<
  852. Codegen.UpdateProductVariantsMutation,
  853. Codegen.UpdateProductVariantsMutationVariables
  854. >(UPDATE_PRODUCT_VARIANTS, {
  855. input: [
  856. {
  857. id: 'T_4',
  858. stockOnHand: 10,
  859. },
  860. ],
  861. });
  862. expect(updateProductVariants[0]!.stockOnHand).toBe(10);
  863. const { addFulfillmentToOrder } = await adminClient.query<
  864. Codegen.CreateFulfillmentMutation,
  865. Codegen.CreateFulfillmentMutationVariables
  866. >(CREATE_FULFILLMENT, {
  867. input: {
  868. lines: order.lines
  869. .filter(l => l.productVariant.id === 'T_4')
  870. .map(l => ({ orderLineId: l.id, quantity: 5 })),
  871. handler: {
  872. code: manualFulfillmentHandler.code,
  873. arguments: [
  874. { name: 'method', value: 'test method' },
  875. { name: 'trackingCode', value: 'ABC123' },
  876. ],
  877. },
  878. },
  879. });
  880. fulfillmentGuard.assertSuccess(addFulfillmentToOrder);
  881. const product = await getProductWithStockMovement('T_1');
  882. const variant = product!.variants[3];
  883. expect(variant.stockOnHand).toBe(5);
  884. expect(variant.stockAllocated).toBe(0);
  885. });
  886. describe('adjusting stockOnHand with negative outOfStockThreshold', () => {
  887. const variant1Id = 'T_1';
  888. beforeAll(async () => {
  889. await adminClient.query<
  890. Codegen.UpdateProductVariantsMutation,
  891. Codegen.UpdateProductVariantsMutationVariables
  892. >(UPDATE_PRODUCT_VARIANTS, {
  893. input: [
  894. {
  895. id: variant1Id,
  896. stockOnHand: 0,
  897. outOfStockThreshold: -20,
  898. trackInventory: GlobalFlag.TRUE,
  899. useGlobalOutOfStockThreshold: false,
  900. },
  901. ],
  902. });
  903. });
  904. it(
  905. 'attempting to set stockOnHand below outOfStockThreshold throws',
  906. assertThrowsWithMessage(async () => {
  907. const result = await adminClient.query<
  908. Codegen.UpdateStockMutation,
  909. Codegen.UpdateStockMutationVariables
  910. >(UPDATE_STOCK_ON_HAND, {
  911. input: [
  912. {
  913. id: variant1Id,
  914. stockOnHand: -21,
  915. },
  916. ] as UpdateProductVariantInput[],
  917. });
  918. }, 'stockOnHand cannot be a negative value'),
  919. );
  920. it('can set negative stockOnHand that is not less than outOfStockThreshold', async () => {
  921. const result = await adminClient.query<
  922. Codegen.UpdateStockMutation,
  923. Codegen.UpdateStockMutationVariables
  924. >(UPDATE_STOCK_ON_HAND, {
  925. input: [
  926. {
  927. id: variant1Id,
  928. stockOnHand: -10,
  929. },
  930. ] as UpdateProductVariantInput[],
  931. });
  932. expect(result.updateProductVariants[0]!.stockOnHand).toBe(-10);
  933. });
  934. });
  935. describe('edge cases', () => {
  936. const variant5Id = 'T_5';
  937. const variant6Id = 'T_6';
  938. const variant7Id = 'T_7';
  939. beforeAll(async () => {
  940. // First place an order which creates a backorder (excess of allocated units)
  941. await adminClient.query<
  942. Codegen.UpdateProductVariantsMutation,
  943. Codegen.UpdateProductVariantsMutationVariables
  944. >(UPDATE_PRODUCT_VARIANTS, {
  945. input: [
  946. {
  947. id: variant5Id,
  948. stockOnHand: 5,
  949. outOfStockThreshold: -20,
  950. trackInventory: GlobalFlag.TRUE,
  951. useGlobalOutOfStockThreshold: false,
  952. },
  953. {
  954. id: variant6Id,
  955. stockOnHand: 3,
  956. outOfStockThreshold: 0,
  957. trackInventory: GlobalFlag.TRUE,
  958. useGlobalOutOfStockThreshold: false,
  959. },
  960. {
  961. id: variant7Id,
  962. stockOnHand: 3,
  963. outOfStockThreshold: 0,
  964. trackInventory: GlobalFlag.TRUE,
  965. useGlobalOutOfStockThreshold: false,
  966. },
  967. ],
  968. });
  969. await shopClient.asUserWithCredentials('trevor_donnelly96@hotmail.com', 'test');
  970. const { addItemToOrder: add1 } = await shopClient.query<
  971. CodegenShop.AddItemToOrderMutation,
  972. CodegenShop.AddItemToOrderMutationVariables
  973. >(ADD_ITEM_TO_ORDER, {
  974. productVariantId: variant5Id,
  975. quantity: 25,
  976. });
  977. orderGuard.assertSuccess(add1);
  978. await proceedToArrangingPayment(shopClient);
  979. await addPaymentToOrder(shopClient, testSuccessfulPaymentMethod);
  980. });
  981. it('zero saleable stock', async () => {
  982. await shopClient.asUserWithCredentials('hayden.zieme12@hotmail.com', 'test');
  983. // The saleable stock level is now 0 (25 allocated, 5 on hand, -20 threshold)
  984. const { addItemToOrder } = await shopClient.query<
  985. CodegenShop.AddItemToOrderMutation,
  986. CodegenShop.AddItemToOrderMutationVariables
  987. >(ADD_ITEM_TO_ORDER, {
  988. productVariantId: variant5Id,
  989. quantity: 1,
  990. });
  991. orderGuard.assertErrorResult(addItemToOrder);
  992. expect(addItemToOrder.errorCode).toBe(ErrorCode.INSUFFICIENT_STOCK_ERROR);
  993. expect(addItemToOrder.message).toBe(
  994. 'No items were added to the order due to insufficient stock',
  995. );
  996. });
  997. it('negative saleable stock', async () => {
  998. await adminClient.query<
  999. Codegen.UpdateProductVariantsMutation,
  1000. Codegen.UpdateProductVariantsMutationVariables
  1001. >(UPDATE_PRODUCT_VARIANTS, {
  1002. input: [
  1003. {
  1004. id: variant5Id,
  1005. outOfStockThreshold: -10,
  1006. },
  1007. ],
  1008. });
  1009. // The saleable stock level is now -10 (25 allocated, 5 on hand, -10 threshold)
  1010. await shopClient.asUserWithCredentials('marques.sawayn@hotmail.com', 'test');
  1011. const { addItemToOrder } = await shopClient.query<
  1012. CodegenShop.AddItemToOrderMutation,
  1013. CodegenShop.AddItemToOrderMutationVariables
  1014. >(ADD_ITEM_TO_ORDER, {
  1015. productVariantId: variant5Id,
  1016. quantity: 1,
  1017. });
  1018. orderGuard.assertErrorResult(addItemToOrder);
  1019. expect(addItemToOrder.errorCode).toBe(ErrorCode.INSUFFICIENT_STOCK_ERROR);
  1020. expect(addItemToOrder.message).toBe(
  1021. 'No items were added to the order due to insufficient stock',
  1022. );
  1023. });
  1024. // https://github.com/vendurehq/vendure/issues/691
  1025. it('returns InsufficientStockError when tracking inventory & adding too many individually', async () => {
  1026. await shopClient.asAnonymousUser();
  1027. const { addItemToOrder: add1 } = await shopClient.query<
  1028. CodegenShop.AddItemToOrderMutation,
  1029. CodegenShop.AddItemToOrderMutationVariables
  1030. >(ADD_ITEM_TO_ORDER, {
  1031. productVariantId: variant6Id,
  1032. quantity: 3,
  1033. });
  1034. orderGuard.assertSuccess(add1);
  1035. const { addItemToOrder: add2 } = await shopClient.query<
  1036. CodegenShop.AddItemToOrderMutation,
  1037. CodegenShop.AddItemToOrderMutationVariables
  1038. >(ADD_ITEM_TO_ORDER, {
  1039. productVariantId: variant6Id,
  1040. quantity: 1,
  1041. });
  1042. orderGuard.assertErrorResult(add2);
  1043. expect(add2.errorCode).toBe(ErrorCode.INSUFFICIENT_STOCK_ERROR);
  1044. expect(add2.message).toBe('No items were added to the order due to insufficient stock');
  1045. expect((add2 as any).quantityAvailable).toBe(0);
  1046. // Still adds as many as available to the Order
  1047. expect((add2 as any).order.lines[0].productVariant.id).toBe(variant6Id);
  1048. expect((add2 as any).order.lines[0].quantity).toBe(3);
  1049. });
  1050. // https://github.com/vendurehq/vendure/issues/1273
  1051. it('adjustOrderLine when saleable stock changes to zero', async () => {
  1052. await adminClient.query<
  1053. Codegen.UpdateProductVariantsMutation,
  1054. Codegen.UpdateProductVariantsMutationVariables
  1055. >(UPDATE_PRODUCT_VARIANTS, {
  1056. input: [
  1057. {
  1058. id: variant7Id,
  1059. stockOnHand: 10,
  1060. },
  1061. ],
  1062. });
  1063. await shopClient.asAnonymousUser();
  1064. const { addItemToOrder: add1 } = await shopClient.query<
  1065. CodegenShop.AddItemToOrderMutation,
  1066. CodegenShop.AddItemToOrderMutationVariables
  1067. >(ADD_ITEM_TO_ORDER, {
  1068. productVariantId: variant7Id,
  1069. quantity: 1,
  1070. });
  1071. orderGuard.assertSuccess(add1);
  1072. expect(add1.lines.length).toBe(1);
  1073. await adminClient.query<
  1074. Codegen.UpdateProductVariantsMutation,
  1075. Codegen.UpdateProductVariantsMutationVariables
  1076. >(UPDATE_PRODUCT_VARIANTS, {
  1077. input: [
  1078. {
  1079. id: variant7Id,
  1080. stockOnHand: 0,
  1081. },
  1082. ],
  1083. });
  1084. const { adjustOrderLine: add2 } = await shopClient.query<
  1085. CodegenShop.AdjustItemQuantityMutation,
  1086. CodegenShop.AdjustItemQuantityMutationVariables
  1087. >(ADJUST_ITEM_QUANTITY, {
  1088. orderLineId: add1.lines[0].id,
  1089. quantity: 2,
  1090. });
  1091. orderGuard.assertErrorResult(add2);
  1092. expect(add2.errorCode).toBe(ErrorCode.INSUFFICIENT_STOCK_ERROR);
  1093. const { activeOrder } =
  1094. await shopClient.query<CodegenShop.GetActiveOrderQuery>(GET_ACTIVE_ORDER);
  1095. expect(activeOrder!.lines.length).toBe(0);
  1096. });
  1097. // https://github.com/vendurehq/vendure/issues/1557
  1098. it('cancelling an Order only creates Releases for OrderItems that have actually been allocated', async () => {
  1099. const product = await getProductWithStockMovement('T_2');
  1100. const variant6 = product!.variants.find(v => v.id === variant6Id)!;
  1101. expect(variant6.stockOnHand).toBe(3);
  1102. expect(variant6.stockAllocated).toBe(0);
  1103. await shopClient.asUserWithCredentials('trevor_donnelly96@hotmail.com', 'test');
  1104. const { addItemToOrder: add1 } = await shopClient.query<
  1105. CodegenShop.AddItemToOrderMutation,
  1106. CodegenShop.AddItemToOrderMutationVariables
  1107. >(ADD_ITEM_TO_ORDER, {
  1108. productVariantId: variant6.id,
  1109. quantity: 1,
  1110. });
  1111. orderGuard.assertSuccess(add1);
  1112. // Set this flag so that our custom OrderPlacedStrategy uses the special logic
  1113. // designed to test this scenario.
  1114. const res = await shopClient.query(UPDATE_ORDER_CUSTOM_FIELDS, {
  1115. input: { customFields: { test1557: true } },
  1116. });
  1117. await shopClient.query<
  1118. CodegenShop.SetShippingAddressMutation,
  1119. CodegenShop.SetShippingAddressMutationVariables
  1120. >(SET_SHIPPING_ADDRESS, {
  1121. input: {
  1122. streetLine1: '1 Test Street',
  1123. countryCode: 'GB',
  1124. } as CreateAddressInput,
  1125. });
  1126. await setFirstEligibleShippingMethod();
  1127. const { transitionOrderToState } = await shopClient.query<
  1128. CodegenShop.TransitionToStateMutation,
  1129. CodegenShop.TransitionToStateMutationVariables
  1130. >(TRANSITION_TO_STATE, { state: 'ArrangingPayment' });
  1131. orderGuard.assertSuccess(transitionOrderToState);
  1132. expect(transitionOrderToState.state).toBe('ArrangingPayment');
  1133. expect(transitionOrderToState.active).toBe(false);
  1134. const product2 = await getProductWithStockMovement('T_2');
  1135. const variant6_2 = product2!.variants.find(v => v.id === variant6Id)!;
  1136. expect(variant6_2.stockOnHand).toBe(3);
  1137. expect(variant6_2.stockAllocated).toBe(0);
  1138. const { cancelOrder } = await adminClient.query<
  1139. Codegen.CancelOrderMutation,
  1140. Codegen.CancelOrderMutationVariables
  1141. >(CANCEL_ORDER, {
  1142. input: {
  1143. orderId: transitionOrderToState.id,
  1144. lines: transitionOrderToState.lines.map(l => ({
  1145. orderLineId: l.id,
  1146. quantity: l.quantity,
  1147. })),
  1148. reason: 'Cancelled by test',
  1149. },
  1150. });
  1151. orderGuard.assertSuccess(cancelOrder);
  1152. const product3 = await getProductWithStockMovement('T_2');
  1153. const variant6_3 = product3!.variants.find(v => v.id === variant6Id)!;
  1154. expect(variant6_3.stockOnHand).toBe(3);
  1155. expect(variant6_3.stockAllocated).toBe(0);
  1156. });
  1157. });
  1158. });
  1159. // https://github.com/vendurehq/vendure/issues/1028
  1160. describe('OrderLines with same variant but different custom fields', () => {
  1161. let orderId: string;
  1162. const ADD_ITEM_TO_ORDER_WITH_CUSTOM_FIELDS = `
  1163. mutation AddItemToOrderWithCustomFields(
  1164. $productVariantId: ID!
  1165. $quantity: Int!
  1166. $customFields: OrderLineCustomFieldsInput
  1167. ) {
  1168. addItemToOrder(
  1169. productVariantId: $productVariantId
  1170. quantity: $quantity
  1171. customFields: $customFields
  1172. ) {
  1173. ... on Order {
  1174. id
  1175. lines { id }
  1176. }
  1177. ... on ErrorResult {
  1178. errorCode
  1179. message
  1180. }
  1181. }
  1182. }
  1183. `;
  1184. it('correctly allocates stock', async () => {
  1185. await shopClient.asUserWithCredentials('trevor_donnelly96@hotmail.com', 'test');
  1186. const product = await getProductWithStockMovement('T_2');
  1187. const [variant1, variant2, variant3] = product!.variants;
  1188. expect(variant2.stockAllocated).toBe(0);
  1189. await shopClient.query<CodegenShop.AddItemToOrderMutation, any>(
  1190. gql(ADD_ITEM_TO_ORDER_WITH_CUSTOM_FIELDS),
  1191. {
  1192. productVariantId: variant2.id,
  1193. quantity: 1,
  1194. customFields: {
  1195. customization: 'foo',
  1196. },
  1197. },
  1198. );
  1199. const { addItemToOrder } = await shopClient.query<CodegenShop.AddItemToOrderMutation, any>(
  1200. gql(ADD_ITEM_TO_ORDER_WITH_CUSTOM_FIELDS),
  1201. {
  1202. productVariantId: variant2.id,
  1203. quantity: 1,
  1204. customFields: {
  1205. customization: 'bar',
  1206. },
  1207. },
  1208. );
  1209. orderGuard.assertSuccess(addItemToOrder);
  1210. orderId = addItemToOrder.id;
  1211. // Assert that separate order lines have been created
  1212. expect(addItemToOrder.lines.length).toBe(2);
  1213. await shopClient.query<
  1214. CodegenShop.SetShippingAddressMutation,
  1215. CodegenShop.SetShippingAddressMutationVariables
  1216. >(SET_SHIPPING_ADDRESS, {
  1217. input: {
  1218. streetLine1: '1 Test Street',
  1219. countryCode: 'GB',
  1220. } as CreateAddressInput,
  1221. });
  1222. await setFirstEligibleShippingMethod();
  1223. await shopClient.query<
  1224. CodegenShop.TransitionToStateMutation,
  1225. CodegenShop.TransitionToStateMutationVariables
  1226. >(TRANSITION_TO_STATE, {
  1227. state: 'ArrangingPayment',
  1228. });
  1229. const { addPaymentToOrder: order } = await shopClient.query<
  1230. CodegenShop.AddPaymentToOrderMutation,
  1231. CodegenShop.AddPaymentToOrderMutationVariables
  1232. >(ADD_PAYMENT, {
  1233. input: {
  1234. method: testSuccessfulPaymentMethod.code,
  1235. metadata: {},
  1236. } as PaymentInput,
  1237. });
  1238. orderGuard.assertSuccess(order);
  1239. const product2 = await getProductWithStockMovement('T_2');
  1240. const [variant1_2, variant2_2, variant3_2] = product2!.variants;
  1241. expect(variant2_2.stockAllocated).toBe(2);
  1242. });
  1243. it('correctly creates Sales', async () => {
  1244. const product = await getProductWithStockMovement('T_2');
  1245. const [variant1, variant2, variant3] = product!.variants;
  1246. expect(variant2.stockOnHand).toBe(3);
  1247. const { order } = await adminClient.query<Codegen.GetOrderQuery, Codegen.GetOrderQueryVariables>(
  1248. GET_ORDER,
  1249. {
  1250. id: orderId,
  1251. },
  1252. );
  1253. await adminClient.query<
  1254. Codegen.CreateFulfillmentMutation,
  1255. Codegen.CreateFulfillmentMutationVariables
  1256. >(CREATE_FULFILLMENT, {
  1257. input: {
  1258. lines: order?.lines.map(l => ({ orderLineId: l.id, quantity: l.quantity })) ?? [],
  1259. handler: {
  1260. code: manualFulfillmentHandler.code,
  1261. arguments: [
  1262. { name: 'method', value: 'test method' },
  1263. { name: 'trackingCode', value: 'ABC123' },
  1264. ],
  1265. },
  1266. },
  1267. });
  1268. const product2 = await getProductWithStockMovement('T_2');
  1269. const [variant1_2, variant2_2, variant3_2] = product2!.variants;
  1270. expect(variant2_2.stockAllocated).toBe(0);
  1271. expect(variant2_2.stockOnHand).toBe(1);
  1272. });
  1273. });
  1274. // https://github.com/vendurehq/vendure/issues/1738
  1275. describe('going out of stock after being added to order', () => {
  1276. const variantId = 'T_1';
  1277. beforeAll(async () => {
  1278. const { updateProductVariants } = await adminClient.query<
  1279. Codegen.UpdateStockMutation,
  1280. Codegen.UpdateStockMutationVariables
  1281. >(UPDATE_STOCK_ON_HAND, {
  1282. input: [
  1283. {
  1284. id: variantId,
  1285. stockOnHand: 1,
  1286. trackInventory: GlobalFlag.TRUE,
  1287. useGlobalOutOfStockThreshold: false,
  1288. outOfStockThreshold: 0,
  1289. },
  1290. ] as UpdateProductVariantInput[],
  1291. });
  1292. });
  1293. it('prevents checkout if no saleable stock', async () => {
  1294. // First customer adds to order
  1295. await shopClient.asUserWithCredentials('hayden.zieme12@hotmail.com', 'test');
  1296. const { addItemToOrder: add1 } = await shopClient.query<
  1297. CodegenShop.AddItemToOrderMutation,
  1298. CodegenShop.AddItemToOrderMutationVariables
  1299. >(ADD_ITEM_TO_ORDER, {
  1300. productVariantId: variantId,
  1301. quantity: 1,
  1302. });
  1303. orderGuard.assertSuccess(add1);
  1304. // Second customer adds to order
  1305. await shopClient.asUserWithCredentials('marques.sawayn@hotmail.com', 'test');
  1306. const { addItemToOrder: add2 } = await shopClient.query<
  1307. CodegenShop.AddItemToOrderMutation,
  1308. CodegenShop.AddItemToOrderMutationVariables
  1309. >(ADD_ITEM_TO_ORDER, {
  1310. productVariantId: variantId,
  1311. quantity: 1,
  1312. });
  1313. orderGuard.assertSuccess(add2);
  1314. // first customer can check out
  1315. await shopClient.asUserWithCredentials('hayden.zieme12@hotmail.com', 'test');
  1316. await proceedToArrangingPayment(shopClient);
  1317. const result1 = await addPaymentToOrder(shopClient, testSuccessfulPaymentMethod);
  1318. orderGuard.assertSuccess(result1);
  1319. const product1 = await getProductWithStockMovement('T_1');
  1320. const variant = product1?.variants.find(v => v.id === variantId);
  1321. expect(variant!.stockOnHand).toBe(1);
  1322. expect(variant!.stockAllocated).toBe(1);
  1323. // second customer CANNOT check out
  1324. await shopClient.asUserWithCredentials('marques.sawayn@hotmail.com', 'test');
  1325. await shopClient.query<
  1326. CodegenShop.SetShippingAddressMutation,
  1327. CodegenShop.SetShippingAddressMutationVariables
  1328. >(SET_SHIPPING_ADDRESS, {
  1329. input: {
  1330. fullName: 'name',
  1331. streetLine1: '12 the street',
  1332. city: 'foo',
  1333. postalCode: '123456',
  1334. countryCode: 'US',
  1335. },
  1336. });
  1337. const { eligibleShippingMethods } = await shopClient.query<CodegenShop.GetShippingMethodsQuery>(
  1338. GET_ELIGIBLE_SHIPPING_METHODS,
  1339. );
  1340. const { setOrderShippingMethod } = await shopClient.query<
  1341. CodegenShop.SetShippingMethodMutation,
  1342. CodegenShop.SetShippingMethodMutationVariables
  1343. >(SET_SHIPPING_METHOD, {
  1344. id: eligibleShippingMethods[1].id,
  1345. });
  1346. orderGuard.assertSuccess(setOrderShippingMethod);
  1347. const { transitionOrderToState } = await shopClient.query<
  1348. CodegenShop.TransitionToStateMutation,
  1349. CodegenShop.TransitionToStateMutationVariables
  1350. >(TRANSITION_TO_STATE, { state: 'ArrangingPayment' });
  1351. orderGuard.assertErrorResult(transitionOrderToState);
  1352. expect(transitionOrderToState!.transitionError).toBe(
  1353. 'Cannot transition Order to the "ArrangingPayment" state due to insufficient stock of Laptop 13 inch 8GB',
  1354. );
  1355. });
  1356. });
  1357. });
  1358. const UPDATE_STOCK_ON_HAND = gql`
  1359. mutation UpdateStock($input: [UpdateProductVariantInput!]!) {
  1360. updateProductVariants(input: $input) {
  1361. ...VariantWithStock
  1362. }
  1363. }
  1364. ${VARIANT_WITH_STOCK_FRAGMENT}
  1365. `;
  1366. export const TRANSITION_FULFILLMENT_TO_STATE = gql`
  1367. mutation TransitionFulfillmentToState($id: ID!, $state: String!) {
  1368. transitionFulfillmentToState(id: $id, state: $state) {
  1369. ... on Fulfillment {
  1370. id
  1371. state
  1372. nextStates
  1373. createdAt
  1374. }
  1375. ... on ErrorResult {
  1376. errorCode
  1377. message
  1378. }
  1379. ... on FulfillmentStateTransitionError {
  1380. transitionError
  1381. }
  1382. }
  1383. }
  1384. `;
  1385. export const UPDATE_ORDER_CUSTOM_FIELDS = gql`
  1386. mutation UpdateOrderCustomFields($input: UpdateOrderInput!) {
  1387. setOrderCustomFields(input: $input) {
  1388. ... on Order {
  1389. id
  1390. }
  1391. }
  1392. }
  1393. `;