stock-control.e2e-spec.ts 57 KB

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