stock-control.e2e-spec.ts 62 KB

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