order-promotion.e2e-spec.ts 85 KB

1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374757677787980818283848586878889909192939495969798991001011021031041051061071081091101111121131141151161171181191201211221231241251261271281291301311321331341351361371381391401411421431441451461471481491501511521531541551561571581591601611621631641651661671681691701711721731741751761771781791801811821831841851861871881891901911921931941951961971981992002012022032042052062072082092102112122132142152162172182192202212222232242252262272282292302312322332342352362372382392402412422432442452462472482492502512522532542552562572582592602612622632642652662672682692702712722732742752762772782792802812822832842852862872882892902912922932942952962972982993003013023033043053063073083093103113123133143153163173183193203213223233243253263273283293303313323333343353363373383393403413423433443453463473483493503513523533543553563573583593603613623633643653663673683693703713723733743753763773783793803813823833843853863873883893903913923933943953963973983994004014024034044054064074084094104114124134144154164174184194204214224234244254264274284294304314324334344354364374384394404414424434444454464474484494504514524534544554564574584594604614624634644654664674684694704714724734744754764774784794804814824834844854864874884894904914924934944954964974984995005015025035045055065075085095105115125135145155165175185195205215225235245255265275285295305315325335345355365375385395405415425435445455465475485495505515525535545555565575585595605615625635645655665675685695705715725735745755765775785795805815825835845855865875885895905915925935945955965975985996006016026036046056066076086096106116126136146156166176186196206216226236246256266276286296306316326336346356366376386396406416426436446456466476486496506516526536546556566576586596606616626636646656666676686696706716726736746756766776786796806816826836846856866876886896906916926936946956966976986997007017027037047057067077087097107117127137147157167177187197207217227237247257267277287297307317327337347357367377387397407417427437447457467477487497507517527537547557567577587597607617627637647657667677687697707717727737747757767777787797807817827837847857867877887897907917927937947957967977987998008018028038048058068078088098108118128138148158168178188198208218228238248258268278288298308318328338348358368378388398408418428438448458468478488498508518528538548558568578588598608618628638648658668678688698708718728738748758768778788798808818828838848858868878888898908918928938948958968978988999009019029039049059069079089099109119129139149159169179189199209219229239249259269279289299309319329339349359369379389399409419429439449459469479489499509519529539549559569579589599609619629639649659669679689699709719729739749759769779789799809819829839849859869879889899909919929939949959969979989991000100110021003100410051006100710081009101010111012101310141015101610171018101910201021102210231024102510261027102810291030103110321033103410351036103710381039104010411042104310441045104610471048104910501051105210531054105510561057105810591060106110621063106410651066106710681069107010711072107310741075107610771078107910801081108210831084108510861087108810891090109110921093109410951096109710981099110011011102110311041105110611071108110911101111111211131114111511161117111811191120112111221123112411251126112711281129113011311132113311341135113611371138113911401141114211431144114511461147114811491150115111521153115411551156115711581159116011611162116311641165116611671168116911701171117211731174117511761177117811791180118111821183118411851186118711881189119011911192119311941195119611971198119912001201120212031204120512061207120812091210121112121213121412151216121712181219122012211222122312241225122612271228122912301231123212331234123512361237123812391240124112421243124412451246124712481249125012511252125312541255125612571258125912601261126212631264126512661267126812691270127112721273127412751276127712781279128012811282128312841285128612871288128912901291129212931294129512961297129812991300130113021303130413051306130713081309131013111312131313141315131613171318131913201321132213231324132513261327132813291330133113321333133413351336133713381339134013411342134313441345134613471348134913501351135213531354135513561357135813591360136113621363136413651366136713681369137013711372137313741375137613771378137913801381138213831384138513861387138813891390139113921393139413951396139713981399140014011402140314041405140614071408140914101411141214131414141514161417141814191420142114221423142414251426142714281429143014311432143314341435143614371438143914401441144214431444144514461447144814491450145114521453145414551456145714581459146014611462146314641465146614671468146914701471147214731474147514761477147814791480148114821483148414851486148714881489149014911492149314941495149614971498149915001501150215031504150515061507150815091510151115121513151415151516151715181519152015211522152315241525152615271528152915301531153215331534153515361537153815391540154115421543154415451546154715481549155015511552155315541555155615571558155915601561156215631564156515661567156815691570157115721573157415751576157715781579158015811582158315841585158615871588158915901591159215931594159515961597159815991600160116021603160416051606160716081609161016111612161316141615161616171618161916201621162216231624162516261627162816291630163116321633163416351636163716381639164016411642164316441645164616471648164916501651165216531654165516561657165816591660166116621663166416651666166716681669167016711672167316741675167616771678167916801681168216831684168516861687168816891690169116921693169416951696169716981699170017011702170317041705170617071708170917101711171217131714171517161717171817191720172117221723172417251726172717281729173017311732173317341735173617371738173917401741174217431744174517461747174817491750175117521753175417551756175717581759176017611762176317641765176617671768176917701771177217731774177517761777177817791780178117821783178417851786178717881789179017911792179317941795179617971798179918001801180218031804180518061807180818091810181118121813181418151816181718181819182018211822182318241825182618271828182918301831183218331834183518361837183818391840184118421843184418451846184718481849185018511852185318541855185618571858185918601861186218631864186518661867186818691870187118721873187418751876187718781879188018811882188318841885188618871888188918901891189218931894189518961897189818991900190119021903190419051906190719081909191019111912191319141915191619171918191919201921192219231924192519261927192819291930193119321933193419351936193719381939194019411942194319441945194619471948194919501951195219531954195519561957195819591960196119621963196419651966196719681969197019711972197319741975197619771978197919801981198219831984198519861987198819891990199119921993199419951996199719981999200020012002200320042005200620072008200920102011201220132014201520162017201820192020202120222023202420252026202720282029203020312032203320342035
  1. /* eslint-disable @typescript-eslint/no-non-null-assertion */
  2. import {
  3. AdjustmentType,
  4. CurrencyCode,
  5. ErrorCode,
  6. HistoryEntryType,
  7. LanguageCode,
  8. } from '@vendure/common/lib/generated-types';
  9. import { omit } from '@vendure/common/lib/omit';
  10. import { pick } from '@vendure/common/lib/pick';
  11. import {
  12. containsProducts,
  13. customerGroup,
  14. defaultShippingCalculator,
  15. defaultShippingEligibilityChecker,
  16. discountOnItemWithFacets,
  17. hasFacetValues,
  18. manualFulfillmentHandler,
  19. mergeConfig,
  20. minimumOrderAmount,
  21. orderPercentageDiscount,
  22. productsPercentageDiscount,
  23. } from '@vendure/core';
  24. import {
  25. createErrorResultGuard,
  26. createTestEnvironment,
  27. E2E_DEFAULT_CHANNEL_TOKEN,
  28. ErrorResultGuard,
  29. } from '@vendure/testing';
  30. import path from 'path';
  31. import { afterAll, beforeAll, beforeEach, describe, expect, it } from 'vitest';
  32. import { initialData } from '../../../e2e-common/e2e-initial-data';
  33. import { TEST_SETUP_TIMEOUT_MS, testConfig } from '../../../e2e-common/test-config';
  34. import { freeShipping } from '../src/config/promotion/actions/free-shipping-action';
  35. import { orderFixedDiscount } from '../src/config/promotion/actions/order-fixed-discount-action';
  36. import { orderLineFixedDiscount } from '../src/config/promotion/actions/order-line-fixed-discount-action';
  37. import { TestMoneyStrategy } from './fixtures/test-money-strategy';
  38. import { testSuccessfulPaymentMethod } from './fixtures/test-payment-methods';
  39. import { channelFragment, promotionFragment } from './graphql/fragments-admin';
  40. import { FragmentOf, ResultOf, VariablesOf } from './graphql/graphql-admin';
  41. import {
  42. assignProductToChannelDocument,
  43. assignPromotionsToChannelDocument,
  44. cancelOrderDocument,
  45. createChannelDocument,
  46. createCustomerGroupDocument,
  47. createPromotionDocument,
  48. createShippingMethodDocument,
  49. deletePromotionDocument,
  50. getFacetListDocument,
  51. getProductsWithVariantPricesDocument,
  52. removeCustomersFromGroupDocument,
  53. } from './graphql/shared-definitions';
  54. import {
  55. addItemToOrderDocument,
  56. adjustItemQuantityDocument,
  57. applyCouponCodeDocument,
  58. getActiveOrderDocument,
  59. getOrderPromotionsByCodeDocument,
  60. removeCouponCodeDocument,
  61. removeItemFromOrderDocument,
  62. setCustomerDocument,
  63. setShippingMethodDocument,
  64. testOrderFragment,
  65. testOrderWithPaymentsFragment,
  66. updatedOrderFragment,
  67. } from './graphql/shop-definitions';
  68. import { addPaymentToOrder, proceedToArrangingPayment } from './utils/test-order-utils';
  69. describe('Promotions applied to Orders', () => {
  70. const { server, adminClient, shopClient } = createTestEnvironment(
  71. mergeConfig(testConfig(), {
  72. dbConnectionOptions: { logging: true },
  73. paymentOptions: {
  74. paymentMethodHandlers: [testSuccessfulPaymentMethod],
  75. },
  76. entityOptions: {
  77. moneyStrategy: new TestMoneyStrategy(),
  78. },
  79. }),
  80. );
  81. const freeOrderAction = {
  82. code: orderPercentageDiscount.code,
  83. arguments: [{ name: 'discount', value: '100' }],
  84. };
  85. const minOrderAmountCondition = (min: number) => ({
  86. code: minimumOrderAmount.code,
  87. arguments: [
  88. { name: 'amount', value: min.toString() },
  89. { name: 'taxInclusive', value: 'true' },
  90. ],
  91. });
  92. type OrderSuccessResult = FragmentOf<typeof updatedOrderFragment> | FragmentOf<typeof testOrderFragment>;
  93. const orderResultGuard: ErrorResultGuard<OrderSuccessResult> = createErrorResultGuard(
  94. input => !!input.lines,
  95. );
  96. type PromotionFragment = FragmentOf<typeof promotionFragment>;
  97. type ChannelFragment = FragmentOf<typeof channelFragment>;
  98. type ProductVariant = ResultOf<
  99. typeof getProductsWithVariantPricesDocument
  100. >['products']['items'][number]['variants'][number];
  101. type CreatePromotionInput = VariablesOf<typeof createPromotionDocument>['input'];
  102. let products: ResultOf<typeof getProductsWithVariantPricesDocument>['products']['items'];
  103. beforeAll(async () => {
  104. await server.init({
  105. initialData: {
  106. ...initialData,
  107. paymentMethods: [
  108. {
  109. name: testSuccessfulPaymentMethod.code,
  110. handler: { code: testSuccessfulPaymentMethod.code, arguments: [] },
  111. },
  112. ],
  113. },
  114. productsCsvPath: path.join(__dirname, 'fixtures/e2e-products-promotions.csv'),
  115. customerCount: 2,
  116. });
  117. await adminClient.asSuperAdmin();
  118. await getProducts();
  119. await createGlobalPromotions();
  120. }, TEST_SETUP_TIMEOUT_MS);
  121. afterAll(async () => {
  122. await server.destroy();
  123. });
  124. describe('coupon codes', () => {
  125. const TEST_COUPON_CODE = 'TESTCOUPON';
  126. const EXPIRED_COUPON_CODE = 'EXPIRED';
  127. let promoFreeWithCoupon: PromotionFragment;
  128. let promoFreeWithExpiredCoupon: PromotionFragment;
  129. beforeAll(async () => {
  130. promoFreeWithCoupon = await createPromotion({
  131. enabled: true,
  132. name: 'Free with test coupon',
  133. couponCode: TEST_COUPON_CODE,
  134. conditions: [],
  135. actions: [freeOrderAction],
  136. });
  137. promoFreeWithExpiredCoupon = await createPromotion({
  138. enabled: true,
  139. name: 'Expired coupon',
  140. endsAt: new Date(2010, 0, 0).toISOString(),
  141. couponCode: EXPIRED_COUPON_CODE,
  142. conditions: [],
  143. actions: [freeOrderAction],
  144. });
  145. await shopClient.asAnonymousUser();
  146. await shopClient.query(addItemToOrderDocument, {
  147. productVariantId: getVariantBySlug('item-5000').id,
  148. quantity: 1,
  149. });
  150. });
  151. afterAll(async () => {
  152. await deletePromotion(promoFreeWithCoupon.id);
  153. await deletePromotion(promoFreeWithExpiredCoupon.id);
  154. });
  155. it('applyCouponCode returns error result when code is nonexistant', async () => {
  156. const { applyCouponCode } = await shopClient.query(applyCouponCodeDocument, {
  157. couponCode: 'bad code',
  158. });
  159. orderResultGuard.assertErrorResult(applyCouponCode);
  160. expect(applyCouponCode.message).toBe('Coupon code "bad code" is not valid');
  161. expect(applyCouponCode.errorCode).toBe(ErrorCode.COUPON_CODE_INVALID_ERROR);
  162. });
  163. it('applyCouponCode returns error when code is expired', async () => {
  164. const { applyCouponCode } = await shopClient.query(applyCouponCodeDocument, {
  165. couponCode: EXPIRED_COUPON_CODE,
  166. });
  167. orderResultGuard.assertErrorResult(applyCouponCode);
  168. expect(applyCouponCode.message).toBe(`Coupon code "${EXPIRED_COUPON_CODE}" has expired`);
  169. expect(applyCouponCode.errorCode).toBe(ErrorCode.COUPON_CODE_EXPIRED_ERROR);
  170. });
  171. it('coupon code application is case-sensitive', async () => {
  172. const { applyCouponCode } = await shopClient.query(applyCouponCodeDocument, {
  173. couponCode: TEST_COUPON_CODE.toLowerCase(),
  174. });
  175. orderResultGuard.assertErrorResult(applyCouponCode);
  176. expect(applyCouponCode.message).toBe(
  177. `Coupon code "${TEST_COUPON_CODE.toLowerCase()}" is not valid`,
  178. );
  179. expect(applyCouponCode.errorCode).toBe(ErrorCode.COUPON_CODE_INVALID_ERROR);
  180. });
  181. it('applies a valid coupon code', async () => {
  182. const { applyCouponCode } = await shopClient.query(applyCouponCodeDocument, {
  183. couponCode: TEST_COUPON_CODE,
  184. });
  185. orderResultGuard.assertSuccess(applyCouponCode);
  186. expect(applyCouponCode.couponCodes).toEqual([TEST_COUPON_CODE]);
  187. expect(applyCouponCode.discounts.length).toBe(1);
  188. expect(applyCouponCode.discounts[0].description).toBe('Free with test coupon');
  189. expect(applyCouponCode.totalWithTax).toBe(0);
  190. });
  191. it('order history records application', async () => {
  192. const { activeOrder } = await shopClient.query(getActiveOrderDocument);
  193. expect(activeOrder!.history.items.map(i => omit(i, ['id']))).toEqual([
  194. {
  195. type: HistoryEntryType.ORDER_STATE_TRANSITION,
  196. data: {
  197. from: 'Created',
  198. to: 'AddingItems',
  199. },
  200. },
  201. {
  202. type: HistoryEntryType.ORDER_COUPON_APPLIED,
  203. data: {
  204. couponCode: TEST_COUPON_CODE,
  205. promotionId: 'T_3',
  206. },
  207. },
  208. ]);
  209. });
  210. it('de-duplicates existing codes', async () => {
  211. const { applyCouponCode } = await shopClient.query(applyCouponCodeDocument, {
  212. couponCode: TEST_COUPON_CODE,
  213. });
  214. orderResultGuard.assertSuccess(applyCouponCode);
  215. expect(applyCouponCode.couponCodes).toEqual([TEST_COUPON_CODE]);
  216. });
  217. it('removes a coupon code', async () => {
  218. const { removeCouponCode } = await shopClient.query(removeCouponCodeDocument, {
  219. couponCode: TEST_COUPON_CODE,
  220. });
  221. expect(removeCouponCode!.discounts.length).toBe(0);
  222. expect(removeCouponCode!.totalWithTax).toBe(6000);
  223. });
  224. // https://github.com/vendure-ecommerce/vendure/issues/649
  225. it('discounts array cleared after coupon code removed', async () => {
  226. const { activeOrder } = await shopClient.query(getActiveOrderDocument);
  227. expect(activeOrder?.discounts).toEqual([]);
  228. });
  229. it('order history records removal', async () => {
  230. const { activeOrder } = await shopClient.query(getActiveOrderDocument);
  231. expect(activeOrder!.history.items.map(i => omit(i, ['id']))).toEqual([
  232. {
  233. type: HistoryEntryType.ORDER_STATE_TRANSITION,
  234. data: {
  235. from: 'Created',
  236. to: 'AddingItems',
  237. },
  238. },
  239. {
  240. type: HistoryEntryType.ORDER_COUPON_APPLIED,
  241. data: {
  242. couponCode: TEST_COUPON_CODE,
  243. promotionId: 'T_3',
  244. },
  245. },
  246. {
  247. type: HistoryEntryType.ORDER_COUPON_REMOVED,
  248. data: {
  249. couponCode: TEST_COUPON_CODE,
  250. },
  251. },
  252. ]);
  253. });
  254. it('does not record removal of coupon code that was not added', async () => {
  255. const { removeCouponCode } = await shopClient.query(removeCouponCodeDocument, {
  256. couponCode: 'NOT_THERE',
  257. });
  258. expect(removeCouponCode!.history.items.map(i => omit(i, ['id']))).toEqual([
  259. {
  260. type: HistoryEntryType.ORDER_STATE_TRANSITION,
  261. data: {
  262. from: 'Created',
  263. to: 'AddingItems',
  264. },
  265. },
  266. {
  267. type: HistoryEntryType.ORDER_COUPON_APPLIED,
  268. data: {
  269. couponCode: TEST_COUPON_CODE,
  270. promotionId: 'T_3',
  271. },
  272. },
  273. {
  274. type: HistoryEntryType.ORDER_COUPON_REMOVED,
  275. data: {
  276. couponCode: TEST_COUPON_CODE,
  277. },
  278. },
  279. ]);
  280. });
  281. describe('coupon codes in other channels', () => {
  282. const OTHER_CHANNEL_TOKEN = 'other-channel';
  283. const OTHER_CHANNEL_COUPON_CODE = 'OTHER_CHANNEL_CODE';
  284. beforeAll(async () => {
  285. await adminClient.query(createChannelDocument, {
  286. input: {
  287. code: 'other-channel',
  288. currencyCode: CurrencyCode.GBP,
  289. pricesIncludeTax: false,
  290. defaultTaxZoneId: 'T_1',
  291. defaultShippingZoneId: 'T_1',
  292. defaultLanguageCode: LanguageCode.en,
  293. token: OTHER_CHANNEL_TOKEN,
  294. },
  295. });
  296. await createPromotion({
  297. enabled: true,
  298. name: 'Other Channel Promo',
  299. couponCode: OTHER_CHANNEL_COUPON_CODE,
  300. conditions: [],
  301. actions: [freeOrderAction],
  302. });
  303. });
  304. afterAll(() => {
  305. shopClient.setChannelToken(E2E_DEFAULT_CHANNEL_TOKEN);
  306. });
  307. // https://github.com/vendure-ecommerce/vendure/issues/1692
  308. it('does not allow a couponCode from another channel', async () => {
  309. shopClient.setChannelToken(OTHER_CHANNEL_TOKEN);
  310. const { applyCouponCode } = await shopClient.query(applyCouponCodeDocument, {
  311. couponCode: OTHER_CHANNEL_COUPON_CODE,
  312. });
  313. orderResultGuard.assertErrorResult(applyCouponCode);
  314. expect(applyCouponCode.errorCode).toEqual('COUPON_CODE_INVALID_ERROR');
  315. });
  316. });
  317. });
  318. describe('default PromotionConditions', () => {
  319. beforeEach(async () => {
  320. await shopClient.asAnonymousUser();
  321. });
  322. it('minimumOrderAmount', async () => {
  323. const promotion = await createPromotion({
  324. enabled: true,
  325. name: 'Free if order total greater than 100',
  326. conditions: [minOrderAmountCondition(10000)],
  327. actions: [freeOrderAction],
  328. });
  329. const { addItemToOrder } = await shopClient.query(addItemToOrderDocument, {
  330. productVariantId: getVariantBySlug('item-5000').id,
  331. quantity: 1,
  332. });
  333. orderResultGuard.assertSuccess(addItemToOrder);
  334. expect(addItemToOrder.totalWithTax).toBe(6000);
  335. expect(addItemToOrder.discounts.length).toBe(0);
  336. const { adjustOrderLine } = await shopClient.query(adjustItemQuantityDocument, {
  337. orderLineId: addItemToOrder.lines[0].id,
  338. quantity: 2,
  339. });
  340. orderResultGuard.assertSuccess(adjustOrderLine);
  341. expect(adjustOrderLine.totalWithTax).toBe(0);
  342. expect(adjustOrderLine.discounts[0].description).toBe('Free if order total greater than 100');
  343. expect(adjustOrderLine.discounts[0].amountWithTax).toBe(-12000);
  344. await deletePromotion(promotion.id);
  345. });
  346. it('atLeastNWithFacets', async () => {
  347. const { facets } = await adminClient.query(getFacetListDocument);
  348. const saleFacetValue = facets.items[0].values[0];
  349. const promotion = await createPromotion({
  350. enabled: true,
  351. name: 'Free if order contains 2 items with Sale facet value',
  352. conditions: [
  353. {
  354. code: hasFacetValues.code,
  355. arguments: [
  356. { name: 'minimum', value: '2' },
  357. { name: 'facets', value: `["${saleFacetValue.id}"]` },
  358. ],
  359. },
  360. ],
  361. actions: [freeOrderAction],
  362. });
  363. const { addItemToOrder: res1 } = await shopClient.query(addItemToOrderDocument, {
  364. productVariantId: getVariantBySlug('item-sale-100').id,
  365. quantity: 1,
  366. });
  367. orderResultGuard.assertSuccess(res1);
  368. expect(res1.totalWithTax).toBe(120);
  369. expect(res1.discounts.length).toBe(0);
  370. const { addItemToOrder: res2 } = await shopClient.query(addItemToOrderDocument, {
  371. productVariantId: getVariantBySlug('item-sale-1000').id,
  372. quantity: 1,
  373. });
  374. orderResultGuard.assertSuccess(res2);
  375. expect(res2.totalWithTax).toBe(0);
  376. expect(res2.discounts.length).toBe(1);
  377. expect(res2.totalWithTax).toBe(0);
  378. expect(res2.discounts[0].description).toBe(
  379. 'Free if order contains 2 items with Sale facet value',
  380. );
  381. expect(res2.discounts[0].amountWithTax).toBe(-1320);
  382. await deletePromotion(promotion.id);
  383. });
  384. it('containsProducts', async () => {
  385. const item5000 = getVariantBySlug('item-5000')!;
  386. const item1000 = getVariantBySlug('item-1000')!;
  387. const promotion = await createPromotion({
  388. enabled: true,
  389. name: 'Free if buying 3 or more offer products',
  390. conditions: [
  391. {
  392. code: containsProducts.code,
  393. arguments: [
  394. { name: 'minimum', value: '3' },
  395. {
  396. name: 'productVariantIds',
  397. value: JSON.stringify([item5000.id, item1000.id]),
  398. },
  399. ],
  400. },
  401. ],
  402. actions: [freeOrderAction],
  403. });
  404. await shopClient.query(addItemToOrderDocument, {
  405. productVariantId: item5000.id,
  406. quantity: 1,
  407. });
  408. const { addItemToOrder } = await shopClient.query(addItemToOrderDocument, {
  409. productVariantId: item1000.id,
  410. quantity: 1,
  411. });
  412. orderResultGuard.assertSuccess(addItemToOrder);
  413. expect(addItemToOrder.totalWithTax).toBe(7200);
  414. expect(addItemToOrder.discounts.length).toBe(0);
  415. const { adjustOrderLine } = await shopClient.query(adjustItemQuantityDocument, {
  416. orderLineId: addItemToOrder.lines.find(l => l.productVariant.id === item5000.id)!.id,
  417. quantity: 2,
  418. });
  419. orderResultGuard.assertSuccess(adjustOrderLine);
  420. expect(adjustOrderLine.total).toBe(0);
  421. expect(adjustOrderLine.discounts[0].description).toBe('Free if buying 3 or more offer products');
  422. expect(adjustOrderLine.discounts[0].amountWithTax).toBe(-13200);
  423. await deletePromotion(promotion.id);
  424. });
  425. it('customerGroup', async () => {
  426. const { createCustomerGroup } = await adminClient.query(createCustomerGroupDocument, {
  427. input: { name: 'Test Group', customerIds: ['T_1'] },
  428. });
  429. await shopClient.asUserWithCredentials('hayden.zieme12@hotmail.com', 'test');
  430. const promotion = await createPromotion({
  431. enabled: true,
  432. name: 'Free for group members',
  433. conditions: [
  434. {
  435. code: customerGroup.code,
  436. arguments: [{ name: 'customerGroupId', value: createCustomerGroup.id }],
  437. },
  438. ],
  439. actions: [freeOrderAction],
  440. });
  441. const { addItemToOrder } = await shopClient.query(addItemToOrderDocument, {
  442. productVariantId: getVariantBySlug('item-5000').id,
  443. quantity: 1,
  444. });
  445. orderResultGuard.assertSuccess(addItemToOrder);
  446. expect(addItemToOrder.totalWithTax).toBe(0);
  447. expect(addItemToOrder.discounts.length).toBe(1);
  448. expect(addItemToOrder.discounts[0].description).toBe('Free for group members');
  449. expect(addItemToOrder.discounts[0].amountWithTax).toBe(-6000);
  450. await adminClient.query(removeCustomersFromGroupDocument, {
  451. groupId: createCustomerGroup.id,
  452. customerIds: ['T_1'],
  453. });
  454. const { adjustOrderLine } = await shopClient.query(adjustItemQuantityDocument, {
  455. orderLineId: addItemToOrder.lines[0].id,
  456. quantity: 2,
  457. });
  458. orderResultGuard.assertSuccess(adjustOrderLine);
  459. expect(adjustOrderLine.totalWithTax).toBe(12000);
  460. expect(adjustOrderLine.discounts.length).toBe(0);
  461. await deletePromotion(promotion.id);
  462. });
  463. });
  464. describe('default PromotionActions', () => {
  465. const TAX_INCLUDED_CHANNEL_TOKEN = 'tax_included_channel';
  466. let taxIncludedChannel: ChannelFragment;
  467. beforeAll(async () => {
  468. // Create a channel where the prices include tax, so we can ensure
  469. // that PromotionActions are working as expected when taxes are included
  470. const { createChannel } = await adminClient.query(createChannelDocument, {
  471. input: {
  472. code: 'tax-included-channel',
  473. currencyCode: CurrencyCode.GBP,
  474. pricesIncludeTax: true,
  475. defaultTaxZoneId: 'T_1',
  476. defaultShippingZoneId: 'T_1',
  477. defaultLanguageCode: LanguageCode.en,
  478. token: TAX_INCLUDED_CHANNEL_TOKEN,
  479. },
  480. });
  481. taxIncludedChannel = createChannel as ChannelFragment;
  482. await adminClient.query(assignProductToChannelDocument, {
  483. input: {
  484. channelId: taxIncludedChannel.id,
  485. priceFactor: 1,
  486. productIds: products.map(p => p.id),
  487. },
  488. });
  489. });
  490. beforeEach(async () => {
  491. await shopClient.asAnonymousUser();
  492. });
  493. async function assignPromotionToTaxIncludedChannel(promotionId: string | string[]) {
  494. await adminClient.query(assignPromotionsToChannelDocument, {
  495. input: {
  496. promotionIds: Array.isArray(promotionId) ? promotionId : [promotionId],
  497. channelId: taxIncludedChannel.id,
  498. },
  499. });
  500. }
  501. describe('orderPercentageDiscount', () => {
  502. const couponCode = '50%_off_order';
  503. let promotion: PromotionFragment;
  504. beforeAll(async () => {
  505. promotion = await createPromotion({
  506. enabled: true,
  507. name: '20% discount on order',
  508. couponCode,
  509. conditions: [],
  510. actions: [
  511. {
  512. code: orderPercentageDiscount.code,
  513. arguments: [{ name: 'discount', value: '20' }],
  514. },
  515. ],
  516. });
  517. await assignPromotionToTaxIncludedChannel(promotion.id);
  518. });
  519. afterAll(async () => {
  520. await deletePromotion(promotion.id);
  521. });
  522. it('prices exclude tax', async () => {
  523. shopClient.setChannelToken(E2E_DEFAULT_CHANNEL_TOKEN);
  524. const { addItemToOrder } = await shopClient.query(addItemToOrderDocument, {
  525. productVariantId: getVariantBySlug('item-5000').id,
  526. quantity: 1,
  527. });
  528. orderResultGuard.assertSuccess(addItemToOrder);
  529. expect(addItemToOrder.totalWithTax).toBe(6000);
  530. expect(addItemToOrder.discounts.length).toBe(0);
  531. const { applyCouponCode } = await shopClient.query(applyCouponCodeDocument, {
  532. couponCode,
  533. });
  534. orderResultGuard.assertSuccess(applyCouponCode);
  535. expect(applyCouponCode.discounts.length).toBe(1);
  536. expect(applyCouponCode.discounts[0].description).toBe('20% discount on order');
  537. expect(applyCouponCode.totalWithTax).toBe(4800);
  538. });
  539. it('prices include tax', async () => {
  540. shopClient.setChannelToken(TAX_INCLUDED_CHANNEL_TOKEN);
  541. const { addItemToOrder } = await shopClient.query(addItemToOrderDocument, {
  542. productVariantId: getVariantBySlug('item-5000').id,
  543. quantity: 1,
  544. });
  545. orderResultGuard.assertSuccess(addItemToOrder);
  546. expect(addItemToOrder.totalWithTax).toBe(6000);
  547. expect(addItemToOrder.discounts.length).toBe(0);
  548. const { applyCouponCode } = await shopClient.query(applyCouponCodeDocument, {
  549. couponCode,
  550. });
  551. orderResultGuard.assertSuccess(applyCouponCode);
  552. expect(applyCouponCode.discounts.length).toBe(1);
  553. expect(applyCouponCode.discounts[0].description).toBe('20% discount on order');
  554. expect(applyCouponCode.totalWithTax).toBe(4800);
  555. });
  556. // https://github.com/vendure-ecommerce/vendure/issues/1773
  557. it('decimal percentage', async () => {
  558. const decimalPercentageCouponCode = 'DPCC';
  559. await createPromotion({
  560. enabled: true,
  561. name: '10.5% discount on order',
  562. couponCode: decimalPercentageCouponCode,
  563. conditions: [],
  564. actions: [
  565. {
  566. code: orderPercentageDiscount.code,
  567. arguments: [{ name: 'discount', value: '10.5' }],
  568. },
  569. ],
  570. });
  571. shopClient.setChannelToken(E2E_DEFAULT_CHANNEL_TOKEN);
  572. const { addItemToOrder } = await shopClient.query(addItemToOrderDocument, {
  573. productVariantId: getVariantBySlug('item-5000').id,
  574. quantity: 1,
  575. });
  576. orderResultGuard.assertSuccess(addItemToOrder);
  577. expect(addItemToOrder.totalWithTax).toBe(6000);
  578. expect(addItemToOrder.discounts.length).toBe(0);
  579. const { applyCouponCode } = await shopClient.query(applyCouponCodeDocument, {
  580. couponCode: decimalPercentageCouponCode,
  581. });
  582. orderResultGuard.assertSuccess(applyCouponCode);
  583. expect(applyCouponCode.discounts.length).toBe(1);
  584. expect(applyCouponCode.discounts[0].description).toBe('10.5% discount on order');
  585. expect(applyCouponCode.totalWithTax).toBe(5370);
  586. });
  587. });
  588. describe('orderFixedDiscount', () => {
  589. const couponCode = '10_off_order';
  590. let promotion: PromotionFragment;
  591. beforeAll(async () => {
  592. promotion = await createPromotion({
  593. enabled: true,
  594. name: '$10 discount on order',
  595. couponCode,
  596. conditions: [],
  597. actions: [
  598. {
  599. code: orderFixedDiscount.code,
  600. arguments: [{ name: 'discount', value: '1000' }],
  601. },
  602. ],
  603. });
  604. await assignPromotionToTaxIncludedChannel(promotion.id);
  605. });
  606. afterAll(async () => {
  607. await deletePromotion(promotion.id);
  608. shopClient.setChannelToken(E2E_DEFAULT_CHANNEL_TOKEN);
  609. });
  610. it('prices exclude tax', async () => {
  611. shopClient.setChannelToken(E2E_DEFAULT_CHANNEL_TOKEN);
  612. const { addItemToOrder } = await shopClient.query(addItemToOrderDocument, {
  613. productVariantId: getVariantBySlug('item-5000').id,
  614. quantity: 1,
  615. });
  616. orderResultGuard.assertSuccess(addItemToOrder);
  617. expect(addItemToOrder.total).toBe(5000);
  618. expect(addItemToOrder.totalWithTax).toBe(6000);
  619. expect(addItemToOrder.discounts.length).toBe(0);
  620. const { applyCouponCode } = await shopClient.query(applyCouponCodeDocument, {
  621. couponCode,
  622. });
  623. orderResultGuard.assertSuccess(applyCouponCode);
  624. expect(applyCouponCode.discounts.length).toBe(1);
  625. expect(applyCouponCode.discounts[0].description).toBe('$10 discount on order');
  626. expect(applyCouponCode.total).toBe(4000);
  627. expect(applyCouponCode.totalWithTax).toBe(4800);
  628. });
  629. it('prices include tax', async () => {
  630. shopClient.setChannelToken(TAX_INCLUDED_CHANNEL_TOKEN);
  631. const { addItemToOrder } = await shopClient.query(addItemToOrderDocument, {
  632. productVariantId: getVariantBySlug('item-5000').id,
  633. quantity: 1,
  634. });
  635. orderResultGuard.assertSuccess(addItemToOrder);
  636. expect(addItemToOrder.totalWithTax).toBe(6000);
  637. expect(addItemToOrder.discounts.length).toBe(0);
  638. const { applyCouponCode } = await shopClient.query(applyCouponCodeDocument, {
  639. couponCode,
  640. });
  641. orderResultGuard.assertSuccess(applyCouponCode);
  642. expect(applyCouponCode.discounts.length).toBe(1);
  643. expect(applyCouponCode.discounts[0].description).toBe('$10 discount on order');
  644. expect(applyCouponCode.totalWithTax).toBe(5000);
  645. });
  646. it('does not result in negative total when shipping is included', async () => {
  647. shopClient.setChannelToken(E2E_DEFAULT_CHANNEL_TOKEN);
  648. const { addItemToOrder } = await shopClient.query(addItemToOrderDocument, {
  649. productVariantId: getVariantBySlug('item-100').id,
  650. quantity: 1,
  651. });
  652. orderResultGuard.assertSuccess(addItemToOrder);
  653. expect(addItemToOrder.totalWithTax).toBe(120);
  654. expect(addItemToOrder.discounts.length).toBe(0);
  655. const { setOrderShippingMethod } = await shopClient.query(setShippingMethodDocument, {
  656. id: ['T_1'],
  657. });
  658. orderResultGuard.assertSuccess(setOrderShippingMethod);
  659. expect(setOrderShippingMethod.totalWithTax).toBe(620);
  660. const { applyCouponCode } = await shopClient.query(applyCouponCodeDocument, {
  661. couponCode,
  662. });
  663. orderResultGuard.assertSuccess(applyCouponCode);
  664. expect(applyCouponCode.discounts.length).toBe(1);
  665. expect(applyCouponCode.discounts[0].description).toBe('$10 discount on order');
  666. expect(applyCouponCode.subTotalWithTax).toBe(0);
  667. expect(applyCouponCode.totalWithTax).toBe(500); // shipping price
  668. });
  669. });
  670. describe('orderLineFixedDiscount', () => {
  671. const couponCode = '1000_off_order_line';
  672. let promotion: PromotionFragment;
  673. beforeAll(async () => {
  674. promotion = await createPromotion({
  675. enabled: true,
  676. name: '$1000 discount on order line',
  677. couponCode,
  678. conditions: [],
  679. actions: [
  680. {
  681. code: orderLineFixedDiscount.code,
  682. arguments: [{ name: 'discount', value: '1000' }],
  683. },
  684. ],
  685. });
  686. });
  687. afterAll(async () => {
  688. await deletePromotion(promotion.id);
  689. });
  690. it('prices exclude tax', async () => {
  691. await shopClient.asAnonymousUser();
  692. const { addItemToOrder } = await shopClient.query(addItemToOrderDocument, {
  693. productVariantId: getVariantBySlug('item-1000').id,
  694. quantity: 3,
  695. });
  696. orderResultGuard.assertSuccess(addItemToOrder);
  697. expect(addItemToOrder.discounts.length).toBe(0);
  698. expect(addItemToOrder.lines[0].discounts.length).toBe(0);
  699. expect(addItemToOrder.total).toBe(3000);
  700. expect(addItemToOrder.totalWithTax).toBe(3600);
  701. const { applyCouponCode } = await shopClient.query(applyCouponCodeDocument, {
  702. couponCode,
  703. });
  704. orderResultGuard.assertSuccess(applyCouponCode);
  705. expect(applyCouponCode.total).toBe(2000);
  706. expect(applyCouponCode.totalWithTax).toBe(2400);
  707. expect(applyCouponCode.lines[0].discounts.length).toBe(1);
  708. });
  709. });
  710. describe('discountOnItemWithFacets', () => {
  711. const couponCode = '50%_off_sale_items';
  712. let promotion: PromotionFragment;
  713. function getItemSale1Line<
  714. T extends Array<
  715. | FragmentOf<typeof updatedOrderFragment>['lines'][number]
  716. | FragmentOf<typeof testOrderFragment>['lines'][number]
  717. >,
  718. >(lines: T): T[number] {
  719. return lines.find(l => l.productVariant.id === getVariantBySlug('item-sale-100').id)!;
  720. }
  721. beforeAll(async () => {
  722. const { facets } = await adminClient.query(getFacetListDocument);
  723. const saleFacetValue = facets.items[0].values[0];
  724. promotion = await createPromotion({
  725. enabled: true,
  726. name: '50% off sale items',
  727. couponCode,
  728. conditions: [],
  729. actions: [
  730. {
  731. code: discountOnItemWithFacets.code,
  732. arguments: [
  733. { name: 'discount', value: '50' },
  734. { name: 'facets', value: `["${saleFacetValue.id}"]` },
  735. ],
  736. },
  737. ],
  738. });
  739. await assignPromotionToTaxIncludedChannel(promotion.id);
  740. });
  741. afterAll(async () => {
  742. await deletePromotion(promotion.id);
  743. shopClient.setChannelToken(E2E_DEFAULT_CHANNEL_TOKEN);
  744. });
  745. it('prices exclude tax', async () => {
  746. await shopClient.query(addItemToOrderDocument, {
  747. productVariantId: getVariantBySlug('item-1000').id,
  748. quantity: 1,
  749. });
  750. await shopClient.query(addItemToOrderDocument, {
  751. productVariantId: getVariantBySlug('item-sale-1000').id,
  752. quantity: 1,
  753. });
  754. const { addItemToOrder } = await shopClient.query(addItemToOrderDocument, {
  755. productVariantId: getVariantBySlug('item-sale-100').id,
  756. quantity: 2,
  757. });
  758. orderResultGuard.assertSuccess(addItemToOrder);
  759. expect(addItemToOrder.discounts.length).toBe(0);
  760. expect(getItemSale1Line(addItemToOrder.lines).discounts.length).toBe(0);
  761. expect(addItemToOrder.total).toBe(2200);
  762. expect(addItemToOrder.totalWithTax).toBe(2640);
  763. const { applyCouponCode } = await shopClient.query(applyCouponCodeDocument, {
  764. couponCode,
  765. });
  766. orderResultGuard.assertSuccess(applyCouponCode);
  767. expect(applyCouponCode.total).toBe(1600);
  768. expect(applyCouponCode.totalWithTax).toBe(1920);
  769. expect(getItemSale1Line(applyCouponCode.lines).discounts.length).toBe(1); // 1x promotion
  770. const { removeCouponCode } = await shopClient.query(removeCouponCodeDocument, {
  771. couponCode,
  772. });
  773. expect(getItemSale1Line(removeCouponCode!.lines).discounts.length).toBe(0);
  774. expect(removeCouponCode!.total).toBe(2200);
  775. expect(removeCouponCode!.totalWithTax).toBe(2640);
  776. const { activeOrder } = await shopClient.query(getActiveOrderDocument);
  777. expect(getItemSale1Line(activeOrder!.lines).discounts.length).toBe(0);
  778. expect(activeOrder!.total).toBe(2200);
  779. expect(activeOrder!.totalWithTax).toBe(2640);
  780. });
  781. it('prices include tax', async () => {
  782. shopClient.setChannelToken(TAX_INCLUDED_CHANNEL_TOKEN);
  783. await shopClient.query(addItemToOrderDocument, {
  784. productVariantId: getVariantBySlug('item-1000').id,
  785. quantity: 1,
  786. });
  787. await shopClient.query(addItemToOrderDocument, {
  788. productVariantId: getVariantBySlug('item-sale-1000').id,
  789. quantity: 1,
  790. });
  791. const { addItemToOrder } = await shopClient.query(addItemToOrderDocument, {
  792. productVariantId: getVariantBySlug('item-sale-100').id,
  793. quantity: 2,
  794. });
  795. orderResultGuard.assertSuccess(addItemToOrder);
  796. expect(addItemToOrder.discounts.length).toBe(0);
  797. expect(getItemSale1Line(addItemToOrder.lines).discounts.length).toBe(0);
  798. expect(addItemToOrder.total).toBe(2200);
  799. expect(addItemToOrder.totalWithTax).toBe(2640);
  800. const { applyCouponCode } = await shopClient.query(applyCouponCodeDocument, {
  801. couponCode,
  802. });
  803. orderResultGuard.assertSuccess(applyCouponCode);
  804. expect(applyCouponCode.total).toBe(1600);
  805. expect(applyCouponCode.totalWithTax).toBe(1920);
  806. expect(getItemSale1Line(applyCouponCode.lines).discounts.length).toBe(1); // 1x promotion
  807. const { removeCouponCode } = await shopClient.query(removeCouponCodeDocument, {
  808. couponCode,
  809. });
  810. expect(getItemSale1Line(removeCouponCode!.lines).discounts.length).toBe(0);
  811. expect(removeCouponCode!.total).toBe(2200);
  812. expect(removeCouponCode!.totalWithTax).toBe(2640);
  813. const { activeOrder } = await shopClient.query(getActiveOrderDocument);
  814. expect(getItemSale1Line(activeOrder!.lines).discounts.length).toBe(0);
  815. expect(activeOrder!.total).toBe(2200);
  816. expect(activeOrder!.totalWithTax).toBe(2640);
  817. });
  818. });
  819. describe('productsPercentageDiscount', () => {
  820. const couponCode = '50%_off_product';
  821. let promotion: PromotionFragment;
  822. beforeAll(async () => {
  823. promotion = await createPromotion({
  824. enabled: true,
  825. name: '50% off product',
  826. couponCode,
  827. conditions: [],
  828. actions: [
  829. {
  830. code: productsPercentageDiscount.code,
  831. arguments: [
  832. { name: 'discount', value: '50' },
  833. {
  834. name: 'productVariantIds',
  835. value: `["${getVariantBySlug('item-5000').id}"]`,
  836. },
  837. ],
  838. },
  839. ],
  840. });
  841. await assignPromotionToTaxIncludedChannel(promotion.id);
  842. });
  843. afterAll(async () => {
  844. await deletePromotion(promotion.id);
  845. shopClient.setChannelToken(E2E_DEFAULT_CHANNEL_TOKEN);
  846. });
  847. it('prices exclude tax', async () => {
  848. const { addItemToOrder } = await shopClient.query(addItemToOrderDocument, {
  849. productVariantId: getVariantBySlug('item-5000').id,
  850. quantity: 1,
  851. });
  852. orderResultGuard.assertSuccess(addItemToOrder);
  853. expect(addItemToOrder.discounts.length).toBe(0);
  854. expect(addItemToOrder.lines[0].discounts.length).toBe(0);
  855. expect(addItemToOrder.total).toBe(5000);
  856. expect(addItemToOrder.totalWithTax).toBe(6000);
  857. const { applyCouponCode } = await shopClient.query(applyCouponCodeDocument, {
  858. couponCode,
  859. });
  860. orderResultGuard.assertSuccess(applyCouponCode);
  861. expect(applyCouponCode.total).toBe(2500);
  862. expect(applyCouponCode.totalWithTax).toBe(3000);
  863. expect(applyCouponCode.lines[0].discounts.length).toBe(1); // 1x promotion
  864. });
  865. it('prices include tax', async () => {
  866. shopClient.setChannelToken(TAX_INCLUDED_CHANNEL_TOKEN);
  867. const { addItemToOrder } = await shopClient.query(addItemToOrderDocument, {
  868. productVariantId: getVariantBySlug('item-5000').id,
  869. quantity: 1,
  870. });
  871. orderResultGuard.assertSuccess(addItemToOrder);
  872. expect(addItemToOrder.discounts.length).toBe(0);
  873. expect(addItemToOrder.lines[0].discounts.length).toBe(0);
  874. expect(addItemToOrder.total).toBe(5000);
  875. expect(addItemToOrder.totalWithTax).toBe(6000);
  876. const { applyCouponCode } = await shopClient.query(applyCouponCodeDocument, {
  877. couponCode,
  878. });
  879. orderResultGuard.assertSuccess(applyCouponCode);
  880. expect(applyCouponCode.total).toBe(2500);
  881. expect(applyCouponCode.totalWithTax).toBe(3000);
  882. expect(applyCouponCode.lines[0].discounts.length).toBe(1); // 1x promotion
  883. });
  884. });
  885. describe('freeShipping', () => {
  886. const couponCode = 'FREE_SHIPPING';
  887. let promotion: PromotionFragment;
  888. // The test shipping method needs to be created in each Channel, since ShippingMethods
  889. // are ChannelAware
  890. async function createTestShippingMethod(channelToken: string) {
  891. adminClient.setChannelToken(channelToken);
  892. const result = await adminClient.query(createShippingMethodDocument, {
  893. input: {
  894. code: 'test-method',
  895. fulfillmentHandler: manualFulfillmentHandler.code,
  896. checker: {
  897. code: defaultShippingEligibilityChecker.code,
  898. arguments: [
  899. {
  900. name: 'orderMinimum',
  901. value: '0',
  902. },
  903. ],
  904. },
  905. calculator: {
  906. code: defaultShippingCalculator.code,
  907. arguments: [
  908. { name: 'rate', value: '345' },
  909. { name: 'includesTax', value: 'auto' },
  910. { name: 'taxRate', value: '20' },
  911. ],
  912. },
  913. translations: [
  914. { languageCode: LanguageCode.en, name: 'test method', description: '' },
  915. ],
  916. },
  917. });
  918. adminClient.setChannelToken(E2E_DEFAULT_CHANNEL_TOKEN);
  919. return result.createShippingMethod;
  920. }
  921. beforeAll(async () => {
  922. promotion = await createPromotion({
  923. enabled: true,
  924. name: 'Free shipping',
  925. couponCode,
  926. conditions: [],
  927. actions: [
  928. {
  929. code: freeShipping.code,
  930. arguments: [],
  931. },
  932. ],
  933. });
  934. await assignPromotionToTaxIncludedChannel(promotion.id);
  935. });
  936. afterAll(async () => {
  937. await deletePromotion(promotion.id);
  938. shopClient.setChannelToken(E2E_DEFAULT_CHANNEL_TOKEN);
  939. });
  940. it('prices exclude tax', async () => {
  941. const { addItemToOrder } = await shopClient.query(addItemToOrderDocument, {
  942. productVariantId: getVariantBySlug('item-5000').id,
  943. quantity: 1,
  944. });
  945. const method = await createTestShippingMethod(E2E_DEFAULT_CHANNEL_TOKEN);
  946. const { setOrderShippingMethod } = await shopClient.query(setShippingMethodDocument, {
  947. id: [method.id],
  948. });
  949. orderResultGuard.assertSuccess(setOrderShippingMethod);
  950. expect(setOrderShippingMethod.discounts).toEqual([]);
  951. expect(setOrderShippingMethod.shipping).toBe(345);
  952. expect(setOrderShippingMethod.shippingWithTax).toBe(414);
  953. expect(setOrderShippingMethod.total).toBe(5345);
  954. expect(setOrderShippingMethod.totalWithTax).toBe(6414);
  955. const { applyCouponCode } = await shopClient.query(applyCouponCodeDocument, {
  956. couponCode,
  957. });
  958. orderResultGuard.assertSuccess(applyCouponCode);
  959. expect(applyCouponCode.discounts.length).toBe(1);
  960. expect(applyCouponCode.discounts[0].description).toBe('Free shipping');
  961. expect(applyCouponCode.shipping).toBe(0);
  962. expect(applyCouponCode.shippingWithTax).toBe(0);
  963. expect(applyCouponCode.total).toBe(5000);
  964. expect(applyCouponCode.totalWithTax).toBe(6000);
  965. });
  966. it('prices include tax', async () => {
  967. shopClient.setChannelToken(TAX_INCLUDED_CHANNEL_TOKEN);
  968. const { addItemToOrder } = await shopClient.query(addItemToOrderDocument, {
  969. productVariantId: getVariantBySlug('item-5000').id,
  970. quantity: 1,
  971. });
  972. const method = await createTestShippingMethod(TAX_INCLUDED_CHANNEL_TOKEN);
  973. const { setOrderShippingMethod } = await shopClient.query(setShippingMethodDocument, {
  974. id: [method.id],
  975. });
  976. orderResultGuard.assertSuccess(setOrderShippingMethod);
  977. expect(setOrderShippingMethod.discounts).toEqual([]);
  978. expect(setOrderShippingMethod.shipping).toBe(288);
  979. expect(setOrderShippingMethod.shippingWithTax).toBe(345);
  980. expect(setOrderShippingMethod.total).toBe(5288);
  981. expect(setOrderShippingMethod.totalWithTax).toBe(6345);
  982. const { applyCouponCode } = await shopClient.query(applyCouponCodeDocument, {
  983. couponCode,
  984. });
  985. orderResultGuard.assertSuccess(applyCouponCode);
  986. expect(applyCouponCode.discounts.length).toBe(1);
  987. expect(applyCouponCode.discounts[0].description).toBe('Free shipping');
  988. expect(applyCouponCode.shipping).toBe(0);
  989. expect(applyCouponCode.shippingWithTax).toBe(0);
  990. expect(applyCouponCode.total).toBe(5000);
  991. expect(applyCouponCode.totalWithTax).toBe(6000);
  992. });
  993. // https://github.com/vendure-ecommerce/vendure/pull/1150
  994. it('shipping discounts get correctly removed', async () => {
  995. shopClient.setChannelToken(TAX_INCLUDED_CHANNEL_TOKEN);
  996. const { addItemToOrder } = await shopClient.query(addItemToOrderDocument, {
  997. productVariantId: getVariantBySlug('item-5000').id,
  998. quantity: 1,
  999. });
  1000. const method = await createTestShippingMethod(TAX_INCLUDED_CHANNEL_TOKEN);
  1001. const { setOrderShippingMethod } = await shopClient.query(setShippingMethodDocument, {
  1002. id: [method.id],
  1003. });
  1004. orderResultGuard.assertSuccess(setOrderShippingMethod);
  1005. expect(setOrderShippingMethod.discounts).toEqual([]);
  1006. expect(setOrderShippingMethod.shippingWithTax).toBe(345);
  1007. const { applyCouponCode } = await shopClient.query(applyCouponCodeDocument, {
  1008. couponCode,
  1009. });
  1010. orderResultGuard.assertSuccess(applyCouponCode);
  1011. expect(applyCouponCode.discounts.length).toBe(1);
  1012. expect(applyCouponCode.discounts[0].description).toBe('Free shipping');
  1013. expect(applyCouponCode.shippingWithTax).toBe(0);
  1014. const { removeCouponCode } = await shopClient.query(removeCouponCodeDocument, {
  1015. couponCode,
  1016. });
  1017. orderResultGuard.assertSuccess(removeCouponCode);
  1018. expect(removeCouponCode.discounts).toEqual([]);
  1019. expect(removeCouponCode.shippingWithTax).toBe(345);
  1020. });
  1021. });
  1022. describe('multiple promotions simultaneously', () => {
  1023. const saleItem50pcOffCoupon = 'CODE1';
  1024. const order15pcOffCoupon = 'CODE2';
  1025. let promotion1: PromotionFragment;
  1026. let promotion2: PromotionFragment;
  1027. beforeAll(async () => {
  1028. const { facets } = await adminClient.query(getFacetListDocument);
  1029. const saleFacetValue = facets.items[0].values[0];
  1030. promotion1 = await createPromotion({
  1031. enabled: true,
  1032. name: 'item promo',
  1033. couponCode: saleItem50pcOffCoupon,
  1034. conditions: [],
  1035. actions: [
  1036. {
  1037. code: discountOnItemWithFacets.code,
  1038. arguments: [
  1039. { name: 'discount', value: '50' },
  1040. { name: 'facets', value: `["${saleFacetValue.id}"]` },
  1041. ],
  1042. },
  1043. ],
  1044. });
  1045. promotion2 = await createPromotion({
  1046. enabled: true,
  1047. name: 'order promo',
  1048. couponCode: order15pcOffCoupon,
  1049. conditions: [],
  1050. actions: [
  1051. {
  1052. code: orderPercentageDiscount.code,
  1053. arguments: [{ name: 'discount', value: '15' }],
  1054. },
  1055. ],
  1056. });
  1057. await assignPromotionToTaxIncludedChannel([promotion1.id, promotion2.id]);
  1058. });
  1059. afterAll(async () => {
  1060. await deletePromotion(promotion1.id);
  1061. await deletePromotion(promotion2.id);
  1062. shopClient.setChannelToken(E2E_DEFAULT_CHANNEL_TOKEN);
  1063. });
  1064. it('prices exclude tax', async () => {
  1065. await shopClient.query(addItemToOrderDocument, {
  1066. productVariantId: getVariantBySlug('item-sale-1000').id,
  1067. quantity: 2,
  1068. });
  1069. await shopClient.query(addItemToOrderDocument, {
  1070. productVariantId: getVariantBySlug('item-5000').id,
  1071. quantity: 1,
  1072. });
  1073. // Apply the OrderItem-level promo
  1074. const { applyCouponCode: apply1 } = await shopClient.query(applyCouponCodeDocument, {
  1075. couponCode: saleItem50pcOffCoupon,
  1076. });
  1077. orderResultGuard.assertSuccess(apply1);
  1078. const saleItemLine = apply1.lines.find(
  1079. l => l.productVariant.id === getVariantBySlug('item-sale-1000').id,
  1080. )!;
  1081. expect(saleItemLine.discounts.length).toBe(1); // 1x promotion
  1082. expect(
  1083. saleItemLine.discounts.find(a => a.type === AdjustmentType.PROMOTION)?.description,
  1084. ).toBe('item promo');
  1085. expect(apply1.discounts.length).toBe(1);
  1086. expect(apply1.total).toBe(6000);
  1087. expect(apply1.totalWithTax).toBe(7200);
  1088. // Apply the Order-level promo
  1089. const { applyCouponCode: apply2 } = await shopClient.query(applyCouponCodeDocument, {
  1090. couponCode: order15pcOffCoupon,
  1091. });
  1092. orderResultGuard.assertSuccess(apply2);
  1093. expect(apply2.discounts.map(d => d.description).sort()).toEqual([
  1094. 'item promo',
  1095. 'order promo',
  1096. ]);
  1097. expect(apply2.total).toBe(5100);
  1098. expect(apply2.totalWithTax).toBe(6120);
  1099. });
  1100. it('prices include tax', async () => {
  1101. shopClient.setChannelToken(TAX_INCLUDED_CHANNEL_TOKEN);
  1102. await shopClient.query(addItemToOrderDocument, {
  1103. productVariantId: getVariantBySlug('item-sale-1000').id,
  1104. quantity: 2,
  1105. });
  1106. await shopClient.query(addItemToOrderDocument, {
  1107. productVariantId: getVariantBySlug('item-5000').id,
  1108. quantity: 1,
  1109. });
  1110. // Apply the OrderItem-level promo
  1111. const { applyCouponCode: apply1 } = await shopClient.query(applyCouponCodeDocument, {
  1112. couponCode: saleItem50pcOffCoupon,
  1113. });
  1114. orderResultGuard.assertSuccess(apply1);
  1115. const saleItemLine = apply1.lines.find(
  1116. l => l.productVariant.id === getVariantBySlug('item-sale-1000').id,
  1117. )!;
  1118. expect(saleItemLine.discounts.length).toBe(1); // 1x promotion
  1119. expect(
  1120. saleItemLine.discounts.find(a => a.type === AdjustmentType.PROMOTION)?.description,
  1121. ).toBe('item promo');
  1122. expect(apply1.discounts.length).toBe(1);
  1123. expect(apply1.total).toBe(6000);
  1124. expect(apply1.totalWithTax).toBe(7200);
  1125. // Apply the Order-level promo
  1126. const { applyCouponCode: apply2 } = await shopClient.query(applyCouponCodeDocument, {
  1127. couponCode: order15pcOffCoupon,
  1128. });
  1129. orderResultGuard.assertSuccess(apply2);
  1130. expect(apply2.discounts.map(d => d.description).sort()).toEqual([
  1131. 'item promo',
  1132. 'order promo',
  1133. ]);
  1134. expect(apply2.total).toBe(5100);
  1135. expect(apply2.totalWithTax).toBe(6120);
  1136. });
  1137. });
  1138. });
  1139. describe('per-customer usage limit', () => {
  1140. const TEST_COUPON_CODE = 'TESTCOUPON';
  1141. const orderGuard: ErrorResultGuard<FragmentOf<typeof testOrderWithPaymentsFragment>> =
  1142. createErrorResultGuard(input => !!input.lines);
  1143. let promoWithUsageLimit: PromotionFragment;
  1144. beforeAll(async () => {
  1145. promoWithUsageLimit = await createPromotion({
  1146. enabled: true,
  1147. name: 'Free with test coupon',
  1148. couponCode: TEST_COUPON_CODE,
  1149. perCustomerUsageLimit: 1,
  1150. conditions: [],
  1151. actions: [freeOrderAction],
  1152. });
  1153. });
  1154. afterAll(async () => {
  1155. await deletePromotion(promoWithUsageLimit.id);
  1156. });
  1157. async function createNewActiveOrder() {
  1158. const { addItemToOrder } = await shopClient.query(addItemToOrderDocument, {
  1159. productVariantId: getVariantBySlug('item-5000').id,
  1160. quantity: 1,
  1161. });
  1162. return addItemToOrder;
  1163. }
  1164. describe('guest customer', () => {
  1165. const GUEST_EMAIL_ADDRESS = 'guest@test.com';
  1166. let orderCode: string;
  1167. function addGuestCustomerToOrder() {
  1168. return shopClient.query(setCustomerDocument, {
  1169. input: {
  1170. emailAddress: GUEST_EMAIL_ADDRESS,
  1171. firstName: 'Guest',
  1172. lastName: 'Customer',
  1173. },
  1174. });
  1175. }
  1176. it('allows initial usage', async () => {
  1177. await shopClient.asAnonymousUser();
  1178. await createNewActiveOrder();
  1179. await addGuestCustomerToOrder();
  1180. const { applyCouponCode } = await shopClient.query(applyCouponCodeDocument, {
  1181. couponCode: TEST_COUPON_CODE,
  1182. });
  1183. orderResultGuard.assertSuccess(applyCouponCode);
  1184. expect(applyCouponCode.totalWithTax).toBe(0);
  1185. expect(applyCouponCode.couponCodes).toEqual([TEST_COUPON_CODE]);
  1186. await proceedToArrangingPayment(shopClient);
  1187. const order = await addPaymentToOrder(shopClient, testSuccessfulPaymentMethod);
  1188. orderGuard.assertSuccess(order);
  1189. expect(order.state).toBe('PaymentSettled');
  1190. expect(order.active).toBe(false);
  1191. orderCode = order.code;
  1192. });
  1193. it('adds Promotions to Order once payment arranged', async () => {
  1194. const { orderByCode } = await shopClient.query(getOrderPromotionsByCodeDocument, {
  1195. code: orderCode,
  1196. });
  1197. expect(orderByCode!.promotions.map(pick(['name']))).toEqual([
  1198. { name: 'Free with test coupon' },
  1199. ]);
  1200. });
  1201. it('returns error result when usage exceeds limit', async () => {
  1202. await shopClient.asAnonymousUser();
  1203. await createNewActiveOrder();
  1204. await addGuestCustomerToOrder();
  1205. const { applyCouponCode } = await shopClient.query(applyCouponCodeDocument, {
  1206. couponCode: TEST_COUPON_CODE,
  1207. });
  1208. orderResultGuard.assertErrorResult(applyCouponCode);
  1209. expect(applyCouponCode.message).toEqual(
  1210. 'Coupon code cannot be used more than once per customer',
  1211. );
  1212. });
  1213. it('removes couponCode from order when adding customer after code applied', async () => {
  1214. await shopClient.asAnonymousUser();
  1215. await createNewActiveOrder();
  1216. const { applyCouponCode } = await shopClient.query(applyCouponCodeDocument, {
  1217. couponCode: TEST_COUPON_CODE,
  1218. });
  1219. orderResultGuard.assertSuccess(applyCouponCode);
  1220. expect(applyCouponCode.totalWithTax).toBe(0);
  1221. expect(applyCouponCode.couponCodes).toEqual([TEST_COUPON_CODE]);
  1222. await addGuestCustomerToOrder();
  1223. const { activeOrder } = await shopClient.query(getActiveOrderDocument);
  1224. expect(activeOrder!.couponCodes).toEqual([]);
  1225. expect(activeOrder!.totalWithTax).toBe(6000);
  1226. });
  1227. it('does not remove valid couponCode when setting guest customer', async () => {
  1228. await shopClient.asAnonymousUser();
  1229. await createNewActiveOrder();
  1230. const { applyCouponCode } = await shopClient.query(applyCouponCodeDocument, {
  1231. couponCode: TEST_COUPON_CODE,
  1232. });
  1233. orderResultGuard.assertSuccess(applyCouponCode);
  1234. expect(applyCouponCode.totalWithTax).toBe(0);
  1235. expect(applyCouponCode.couponCodes).toEqual([TEST_COUPON_CODE]);
  1236. await shopClient.query(setCustomerDocument, {
  1237. input: {
  1238. emailAddress: 'new-guest@test.com',
  1239. firstName: 'New Guest',
  1240. lastName: 'Customer',
  1241. },
  1242. });
  1243. const { activeOrder } = await shopClient.query(getActiveOrderDocument);
  1244. orderResultGuard.assertSuccess(activeOrder);
  1245. expect(activeOrder.couponCodes).toEqual([TEST_COUPON_CODE]);
  1246. expect(applyCouponCode.totalWithTax).toBe(0);
  1247. });
  1248. });
  1249. describe('signed-in customer', () => {
  1250. function logInAsRegisteredCustomer() {
  1251. return shopClient.asUserWithCredentials('hayden.zieme12@hotmail.com', 'test');
  1252. }
  1253. let orderId: string;
  1254. it('allows initial usage', async () => {
  1255. await logInAsRegisteredCustomer();
  1256. await createNewActiveOrder();
  1257. const { applyCouponCode } = await shopClient.query(applyCouponCodeDocument, {
  1258. couponCode: TEST_COUPON_CODE,
  1259. });
  1260. orderResultGuard.assertSuccess(applyCouponCode);
  1261. expect(applyCouponCode.totalWithTax).toBe(0);
  1262. expect(applyCouponCode.couponCodes).toEqual([TEST_COUPON_CODE]);
  1263. await proceedToArrangingPayment(shopClient);
  1264. const order = await addPaymentToOrder(shopClient, testSuccessfulPaymentMethod);
  1265. orderGuard.assertSuccess(order);
  1266. orderId = order.id;
  1267. expect(order.state).toBe('PaymentSettled');
  1268. expect(order.active).toBe(false);
  1269. });
  1270. it('returns error result when usage exceeds limit', async () => {
  1271. await logInAsRegisteredCustomer();
  1272. await createNewActiveOrder();
  1273. const { applyCouponCode } = await shopClient.query(applyCouponCodeDocument, {
  1274. couponCode: TEST_COUPON_CODE,
  1275. });
  1276. orderResultGuard.assertErrorResult(applyCouponCode);
  1277. expect(applyCouponCode.message).toEqual(
  1278. 'Coupon code cannot be used more than once per customer',
  1279. );
  1280. expect(applyCouponCode.errorCode).toBe(ErrorCode.COUPON_CODE_LIMIT_ERROR);
  1281. });
  1282. it('removes couponCode from order when logging in after code applied', async () => {
  1283. await shopClient.asAnonymousUser();
  1284. await createNewActiveOrder();
  1285. const { applyCouponCode } = await shopClient.query(applyCouponCodeDocument, {
  1286. couponCode: TEST_COUPON_CODE,
  1287. });
  1288. orderResultGuard.assertSuccess(applyCouponCode);
  1289. expect(applyCouponCode.couponCodes).toEqual([TEST_COUPON_CODE]);
  1290. expect(applyCouponCode.totalWithTax).toBe(0);
  1291. await logInAsRegisteredCustomer();
  1292. const { activeOrder } = await shopClient.query(getActiveOrderDocument);
  1293. expect(activeOrder!.totalWithTax).toBe(6000);
  1294. expect(activeOrder!.couponCodes).toEqual([]);
  1295. });
  1296. // https://github.com/vendure-ecommerce/vendure/issues/1466
  1297. it('cancelled orders do not count against usage limit', async () => {
  1298. const { cancelOrder } = await adminClient.query(cancelOrderDocument, {
  1299. input: {
  1300. orderId,
  1301. cancelShipping: true,
  1302. reason: 'request',
  1303. },
  1304. });
  1305. orderResultGuard.assertSuccess(cancelOrder);
  1306. expect(cancelOrder.state).toBe('Cancelled');
  1307. await logInAsRegisteredCustomer();
  1308. await createNewActiveOrder();
  1309. const { applyCouponCode } = await shopClient.query(applyCouponCodeDocument, {
  1310. couponCode: TEST_COUPON_CODE,
  1311. });
  1312. orderResultGuard.assertSuccess(applyCouponCode);
  1313. expect(applyCouponCode.totalWithTax).toBe(0);
  1314. expect(applyCouponCode.couponCodes).toEqual([TEST_COUPON_CODE]);
  1315. });
  1316. });
  1317. });
  1318. describe('usage limit', () => {
  1319. const TEST_COUPON_CODE = 'TESTCOUPON';
  1320. const orderGuard: ErrorResultGuard<FragmentOf<typeof testOrderWithPaymentsFragment>> =
  1321. createErrorResultGuard(input => !!input.lines);
  1322. let promoWithUsageLimit: PromotionFragment;
  1323. async function createNewActiveOrder() {
  1324. const { addItemToOrder } = await shopClient.query(addItemToOrderDocument, {
  1325. productVariantId: getVariantBySlug('item-5000').id,
  1326. quantity: 1,
  1327. });
  1328. return addItemToOrder;
  1329. }
  1330. describe('guest customer', () => {
  1331. const GUEST_EMAIL_ADDRESS = 'guest@test.com';
  1332. let orderCode: string;
  1333. beforeAll(async () => {
  1334. promoWithUsageLimit = await createPromotion({
  1335. enabled: true,
  1336. name: 'Free with test coupon',
  1337. couponCode: TEST_COUPON_CODE,
  1338. usageLimit: 1,
  1339. conditions: [],
  1340. actions: [freeOrderAction],
  1341. });
  1342. });
  1343. afterAll(async () => {
  1344. await deletePromotion(promoWithUsageLimit.id);
  1345. });
  1346. function addGuestCustomerToOrder() {
  1347. return shopClient.query(setCustomerDocument, {
  1348. input: {
  1349. emailAddress: GUEST_EMAIL_ADDRESS,
  1350. firstName: 'Guest',
  1351. lastName: 'Customer',
  1352. },
  1353. });
  1354. }
  1355. it('allows initial usage', async () => {
  1356. await shopClient.asAnonymousUser();
  1357. await createNewActiveOrder();
  1358. await addGuestCustomerToOrder();
  1359. const { applyCouponCode } = await shopClient.query(applyCouponCodeDocument, {
  1360. couponCode: TEST_COUPON_CODE,
  1361. });
  1362. orderResultGuard.assertSuccess(applyCouponCode);
  1363. expect(applyCouponCode.totalWithTax).toBe(0);
  1364. expect(applyCouponCode.couponCodes).toEqual([TEST_COUPON_CODE]);
  1365. await proceedToArrangingPayment(shopClient);
  1366. const order = await addPaymentToOrder(shopClient, testSuccessfulPaymentMethod);
  1367. orderGuard.assertSuccess(order);
  1368. expect(order.state).toBe('PaymentSettled');
  1369. expect(order.active).toBe(false);
  1370. orderCode = order.code;
  1371. });
  1372. it('adds Promotions to Order once payment arranged', async () => {
  1373. const { orderByCode } = await shopClient.query(getOrderPromotionsByCodeDocument, {
  1374. code: orderCode,
  1375. });
  1376. expect(orderByCode!.promotions.map(pick(['name']))).toEqual([
  1377. { name: 'Free with test coupon' },
  1378. ]);
  1379. });
  1380. it('returns error result when usage exceeds limit', async () => {
  1381. await shopClient.asAnonymousUser();
  1382. await createNewActiveOrder();
  1383. await addGuestCustomerToOrder();
  1384. const { applyCouponCode } = await shopClient.query(applyCouponCodeDocument, {
  1385. couponCode: TEST_COUPON_CODE,
  1386. });
  1387. orderResultGuard.assertErrorResult(applyCouponCode);
  1388. expect(applyCouponCode.message).toEqual(
  1389. 'Coupon code cannot be used more than once per customer',
  1390. );
  1391. });
  1392. });
  1393. describe('signed-in customer', () => {
  1394. beforeAll(async () => {
  1395. promoWithUsageLimit = await createPromotion({
  1396. enabled: true,
  1397. name: 'Free with test coupon',
  1398. couponCode: TEST_COUPON_CODE,
  1399. usageLimit: 1,
  1400. conditions: [],
  1401. actions: [freeOrderAction],
  1402. });
  1403. });
  1404. afterAll(async () => {
  1405. await deletePromotion(promoWithUsageLimit.id);
  1406. });
  1407. function logInAsRegisteredCustomer() {
  1408. return shopClient.asUserWithCredentials('hayden.zieme12@hotmail.com', 'test');
  1409. }
  1410. let orderId: string;
  1411. it('allows initial usage', async () => {
  1412. await logInAsRegisteredCustomer();
  1413. await createNewActiveOrder();
  1414. const { applyCouponCode } = await shopClient.query(applyCouponCodeDocument, {
  1415. couponCode: TEST_COUPON_CODE,
  1416. });
  1417. orderResultGuard.assertSuccess(applyCouponCode);
  1418. expect(applyCouponCode.totalWithTax).toBe(0);
  1419. expect(applyCouponCode.couponCodes).toEqual([TEST_COUPON_CODE]);
  1420. await proceedToArrangingPayment(shopClient);
  1421. const order = await addPaymentToOrder(shopClient, testSuccessfulPaymentMethod);
  1422. orderGuard.assertSuccess(order);
  1423. orderId = order.id;
  1424. expect(order.state).toBe('PaymentSettled');
  1425. expect(order.active).toBe(false);
  1426. });
  1427. it('returns error result when usage exceeds limit', async () => {
  1428. await logInAsRegisteredCustomer();
  1429. await createNewActiveOrder();
  1430. const { applyCouponCode } = await shopClient.query(applyCouponCodeDocument, {
  1431. couponCode: TEST_COUPON_CODE,
  1432. });
  1433. orderResultGuard.assertErrorResult(applyCouponCode);
  1434. expect(applyCouponCode.message).toEqual(
  1435. 'Coupon code cannot be used more than once per customer',
  1436. );
  1437. expect(applyCouponCode.errorCode).toBe(ErrorCode.COUPON_CODE_LIMIT_ERROR);
  1438. });
  1439. // https://github.com/vendure-ecommerce/vendure/issues/1466
  1440. it('cancelled orders do not count against usage limit', async () => {
  1441. const { cancelOrder } = await adminClient.query(cancelOrderDocument, {
  1442. input: {
  1443. orderId,
  1444. cancelShipping: true,
  1445. reason: 'request',
  1446. },
  1447. });
  1448. orderResultGuard.assertSuccess(cancelOrder);
  1449. expect(cancelOrder.state).toBe('Cancelled');
  1450. await logInAsRegisteredCustomer();
  1451. await createNewActiveOrder();
  1452. const { applyCouponCode } = await shopClient.query(applyCouponCodeDocument, {
  1453. couponCode: TEST_COUPON_CODE,
  1454. });
  1455. orderResultGuard.assertSuccess(applyCouponCode);
  1456. expect(applyCouponCode.totalWithTax).toBe(0);
  1457. expect(applyCouponCode.couponCodes).toEqual([TEST_COUPON_CODE]);
  1458. });
  1459. });
  1460. });
  1461. // https://github.com/vendure-ecommerce/vendure/issues/710
  1462. it('removes order-level discount made invalid by removing OrderLine', async () => {
  1463. const promotion = await createPromotion({
  1464. enabled: true,
  1465. name: 'Test Promo',
  1466. conditions: [minOrderAmountCondition(10000)],
  1467. actions: [
  1468. {
  1469. code: orderFixedDiscount.code,
  1470. arguments: [{ name: 'discount', value: '1000' }],
  1471. },
  1472. ],
  1473. });
  1474. await shopClient.asAnonymousUser();
  1475. await shopClient.query(addItemToOrderDocument, {
  1476. productVariantId: getVariantBySlug('item-1000').id,
  1477. quantity: 8,
  1478. });
  1479. const { addItemToOrder } = await shopClient.query(addItemToOrderDocument, {
  1480. productVariantId: getVariantBySlug('item-5000').id,
  1481. quantity: 1,
  1482. });
  1483. orderResultGuard.assertSuccess(addItemToOrder);
  1484. expect(addItemToOrder.discounts.length).toBe(1);
  1485. expect(addItemToOrder.discounts[0].description).toBe('Test Promo');
  1486. const { activeOrder: check1 } = await shopClient.query(getActiveOrderDocument);
  1487. expect(check1!.discounts.length).toBe(1);
  1488. expect(check1!.discounts[0].description).toBe('Test Promo');
  1489. const { removeOrderLine } = await shopClient.query(removeItemFromOrderDocument, {
  1490. orderLineId: addItemToOrder.lines[1].id,
  1491. });
  1492. orderResultGuard.assertSuccess(removeOrderLine);
  1493. expect(removeOrderLine.discounts.length).toBe(0);
  1494. const { activeOrder: check2 } = await shopClient.query(getActiveOrderDocument);
  1495. expect(check2!.discounts.length).toBe(0);
  1496. });
  1497. // https://github.com/vendure-ecommerce/vendure/issues/1492
  1498. it('correctly handles pro-ration of variants with 0 price', async () => {
  1499. const couponCode = '20%_off_order';
  1500. const promotion = await createPromotion({
  1501. enabled: true,
  1502. name: '20% discount on order',
  1503. couponCode,
  1504. conditions: [],
  1505. actions: [
  1506. {
  1507. code: orderPercentageDiscount.code,
  1508. arguments: [{ name: 'discount', value: '20' }],
  1509. },
  1510. ],
  1511. });
  1512. await shopClient.asAnonymousUser();
  1513. await shopClient.query(addItemToOrderDocument, {
  1514. productVariantId: getVariantBySlug('item-100').id,
  1515. quantity: 1,
  1516. });
  1517. await shopClient.query(addItemToOrderDocument, {
  1518. productVariantId: getVariantBySlug('item-0').id,
  1519. quantity: 1,
  1520. });
  1521. const { applyCouponCode } = await shopClient.query(applyCouponCodeDocument, { couponCode });
  1522. orderResultGuard.assertSuccess(applyCouponCode);
  1523. expect(applyCouponCode.totalWithTax).toBe(96);
  1524. });
  1525. // https://github.com/vendure-ecommerce/vendure/issues/2385
  1526. describe('prevents negative line price', () => {
  1527. const TAX_INCLUDED_CHANNEL_TOKEN_2 = 'tax_included_channel_2';
  1528. const couponCode1 = '100%_off';
  1529. const couponCode2 = '100%_off';
  1530. let taxIncludedChannel: ChannelFragment;
  1531. beforeAll(async () => {
  1532. // Create a channel where the prices include tax, so we can ensure
  1533. // that PromotionActions are working as expected when taxes are included
  1534. const { createChannel } = await adminClient.query(createChannelDocument, {
  1535. input: {
  1536. code: 'tax-included-channel-2',
  1537. currencyCode: CurrencyCode.GBP,
  1538. pricesIncludeTax: true,
  1539. defaultTaxZoneId: 'T_1',
  1540. defaultShippingZoneId: 'T_1',
  1541. defaultLanguageCode: LanguageCode.en,
  1542. token: TAX_INCLUDED_CHANNEL_TOKEN_2,
  1543. },
  1544. });
  1545. taxIncludedChannel = createChannel as ChannelFragment;
  1546. await adminClient.query(assignProductToChannelDocument, {
  1547. input: {
  1548. channelId: taxIncludedChannel.id,
  1549. priceFactor: 1,
  1550. productIds: products.map(p => p.id),
  1551. },
  1552. });
  1553. const item1000 = getVariantBySlug('item-1000')!;
  1554. const promo100 = await createPromotion({
  1555. enabled: true,
  1556. name: '100% discount ',
  1557. couponCode: couponCode1,
  1558. conditions: [],
  1559. actions: [
  1560. {
  1561. code: productsPercentageDiscount.code,
  1562. arguments: [
  1563. { name: 'discount', value: '100' },
  1564. {
  1565. name: 'productVariantIds',
  1566. value: `["${item1000.id}"]`,
  1567. },
  1568. ],
  1569. },
  1570. ],
  1571. });
  1572. const promo20 = await createPromotion({
  1573. enabled: true,
  1574. name: '20% discount ',
  1575. couponCode: couponCode2,
  1576. conditions: [],
  1577. actions: [
  1578. {
  1579. code: productsPercentageDiscount.code,
  1580. arguments: [
  1581. { name: 'discount', value: '20' },
  1582. {
  1583. name: 'productVariantIds',
  1584. value: `["${item1000.id}"]`,
  1585. },
  1586. ],
  1587. },
  1588. ],
  1589. });
  1590. await adminClient.query(assignPromotionsToChannelDocument, {
  1591. input: {
  1592. promotionIds: [promo100.id, promo20.id],
  1593. channelId: taxIncludedChannel.id,
  1594. },
  1595. });
  1596. });
  1597. it('prices exclude tax', async () => {
  1598. await shopClient.asAnonymousUser();
  1599. const item1000 = getVariantBySlug('item-1000')!;
  1600. await shopClient.query(applyCouponCodeDocument, { couponCode: couponCode1 });
  1601. await shopClient.query(addItemToOrderDocument, {
  1602. productVariantId: item1000.id,
  1603. quantity: 1,
  1604. });
  1605. const { activeOrder: check1 } = await shopClient.query(getActiveOrderDocument);
  1606. expect(check1!.lines[0].discountedUnitPriceWithTax).toBe(0);
  1607. expect(check1!.totalWithTax).toBe(0);
  1608. await shopClient.query(applyCouponCodeDocument, { couponCode: couponCode2 });
  1609. const { activeOrder: check2 } = await shopClient.query(getActiveOrderDocument);
  1610. expect(check2!.lines[0].discountedUnitPriceWithTax).toBe(0);
  1611. expect(check2!.totalWithTax).toBe(0);
  1612. });
  1613. it('prices include tax', async () => {
  1614. shopClient.setChannelToken(TAX_INCLUDED_CHANNEL_TOKEN_2);
  1615. await shopClient.asAnonymousUser();
  1616. const item1000 = getVariantBySlug('item-1000')!;
  1617. await shopClient.query(applyCouponCodeDocument, { couponCode: couponCode1 });
  1618. await shopClient.query(addItemToOrderDocument, {
  1619. productVariantId: item1000.id,
  1620. quantity: 1,
  1621. });
  1622. const { activeOrder: check1 } = await shopClient.query(getActiveOrderDocument);
  1623. expect(check1!.lines[0].discountedUnitPriceWithTax).toBe(0);
  1624. expect(check1!.totalWithTax).toBe(0);
  1625. await shopClient.query(applyCouponCodeDocument, { couponCode: couponCode2 });
  1626. const { activeOrder: check2 } = await shopClient.query(getActiveOrderDocument);
  1627. expect(check2!.lines[0].discountedUnitPriceWithTax).toBe(0);
  1628. expect(check2!.totalWithTax).toBe(0);
  1629. });
  1630. });
  1631. // https://github.com/vendure-ecommerce/vendure/issues/2052
  1632. describe('multi-channel usage', () => {
  1633. const SECOND_CHANNEL_TOKEN = 'second_channel_token';
  1634. const THIRD_CHANNEL_TOKEN = 'third_channel_token';
  1635. const promoCode = 'TEST_COMMON_CODE';
  1636. async function createChannelAndAssignProducts(code: string, token: string) {
  1637. const result = await adminClient.query(createChannelDocument, {
  1638. input: {
  1639. code,
  1640. token,
  1641. defaultLanguageCode: LanguageCode.en,
  1642. currencyCode: CurrencyCode.GBP,
  1643. pricesIncludeTax: true,
  1644. defaultShippingZoneId: 'T_1',
  1645. defaultTaxZoneId: 'T_1',
  1646. },
  1647. });
  1648. await adminClient.query(assignProductToChannelDocument, {
  1649. input: {
  1650. channelId: (result.createChannel as ChannelFragment).id,
  1651. priceFactor: 1,
  1652. productIds: products.map(p => p.id),
  1653. },
  1654. });
  1655. return result.createChannel as ChannelFragment;
  1656. }
  1657. async function addItemAndApplyPromoCode() {
  1658. await shopClient.asAnonymousUser();
  1659. await shopClient.query(addItemToOrderDocument, {
  1660. productVariantId: getVariantBySlug('item-5000').id,
  1661. quantity: 1,
  1662. });
  1663. const { applyCouponCode } = await shopClient.query(applyCouponCodeDocument, {
  1664. couponCode: promoCode,
  1665. });
  1666. orderResultGuard.assertSuccess(applyCouponCode);
  1667. return applyCouponCode;
  1668. }
  1669. beforeAll(async () => {
  1670. await createChannelAndAssignProducts('second-channel', SECOND_CHANNEL_TOKEN);
  1671. await createChannelAndAssignProducts('third-channel', THIRD_CHANNEL_TOKEN);
  1672. });
  1673. it('create promotion in second channel', async () => {
  1674. adminClient.setChannelToken(SECOND_CHANNEL_TOKEN);
  1675. const result = await createPromotion({
  1676. enabled: true,
  1677. name: 'common-promotion-second-channel',
  1678. couponCode: promoCode,
  1679. actions: [
  1680. {
  1681. code: orderPercentageDiscount.code,
  1682. arguments: [{ name: 'discount', value: '20' }],
  1683. },
  1684. ],
  1685. conditions: [],
  1686. });
  1687. expect(result.name).toBe('common-promotion-second-channel');
  1688. });
  1689. it('create promotion in third channel', async () => {
  1690. adminClient.setChannelToken(THIRD_CHANNEL_TOKEN);
  1691. const result = await createPromotion({
  1692. enabled: true,
  1693. name: 'common-promotion-third-channel',
  1694. couponCode: promoCode,
  1695. actions: [
  1696. {
  1697. code: orderPercentageDiscount.code,
  1698. arguments: [{ name: 'discount', value: '20' }],
  1699. },
  1700. ],
  1701. conditions: [],
  1702. });
  1703. expect(result.name).toBe('common-promotion-third-channel');
  1704. });
  1705. it('applies promotion in second channel', async () => {
  1706. shopClient.setChannelToken(SECOND_CHANNEL_TOKEN);
  1707. const result = await addItemAndApplyPromoCode();
  1708. expect(result.discounts.length).toBe(1);
  1709. expect(result.discounts[0].description).toBe('common-promotion-second-channel');
  1710. });
  1711. it('applies promotion in third channel', async () => {
  1712. shopClient.setChannelToken(THIRD_CHANNEL_TOKEN);
  1713. const result = await addItemAndApplyPromoCode();
  1714. expect(result.discounts.length).toBe(1);
  1715. expect(result.discounts[0].description).toBe('common-promotion-third-channel');
  1716. });
  1717. it('applies promotion from current channel, not default channel', async () => {
  1718. adminClient.setChannelToken(E2E_DEFAULT_CHANNEL_TOKEN);
  1719. const defaultChannelPromotion = await createPromotion({
  1720. enabled: true,
  1721. name: 'common-promotion-default-channel',
  1722. couponCode: promoCode,
  1723. actions: [
  1724. {
  1725. code: orderPercentageDiscount.code,
  1726. arguments: [{ name: 'discount', value: '20' }],
  1727. },
  1728. ],
  1729. conditions: [],
  1730. });
  1731. shopClient.setChannelToken(SECOND_CHANNEL_TOKEN);
  1732. const result = await addItemAndApplyPromoCode();
  1733. expect(result.discounts.length).toBe(1);
  1734. expect(result.discounts[0].description).toBe('common-promotion-second-channel');
  1735. });
  1736. });
  1737. async function getProducts() {
  1738. const result = await adminClient.query(getProductsWithVariantPricesDocument, {
  1739. options: {
  1740. take: 10,
  1741. skip: 0,
  1742. },
  1743. });
  1744. products = result.products.items;
  1745. }
  1746. async function createGlobalPromotions() {
  1747. const { facets } = await adminClient.query(getFacetListDocument);
  1748. const saleFacetValue = facets.items[0].values[0];
  1749. await createPromotion({
  1750. enabled: true,
  1751. name: 'Promo not yet started',
  1752. startsAt: new Date(2199, 0, 0).toISOString(),
  1753. conditions: [minOrderAmountCondition(100)],
  1754. actions: [freeOrderAction],
  1755. });
  1756. const deletedPromotion = await createPromotion({
  1757. enabled: true,
  1758. name: 'Deleted promotion',
  1759. conditions: [minOrderAmountCondition(100)],
  1760. actions: [freeOrderAction],
  1761. });
  1762. await deletePromotion(deletedPromotion.id);
  1763. }
  1764. async function createPromotion(
  1765. input: Omit<CreatePromotionInput, 'translations'> & { name: string },
  1766. ): Promise<PromotionFragment> {
  1767. const correctedInput = {
  1768. ...input,
  1769. translations: [{ languageCode: LanguageCode.en, name: input.name }],
  1770. };
  1771. delete (correctedInput as any).name;
  1772. const result = await adminClient.query(createPromotionDocument, {
  1773. input: correctedInput,
  1774. });
  1775. return result.createPromotion as PromotionFragment;
  1776. }
  1777. function getVariantBySlug(
  1778. slug: 'item-100' | 'item-1000' | 'item-5000' | 'item-sale-100' | 'item-sale-1000' | 'item-0',
  1779. ): ProductVariant {
  1780. return products.find(p => p.slug === slug)!.variants[0];
  1781. }
  1782. async function deletePromotion(promotionId: string) {
  1783. await adminClient.query(deletePromotionDocument, { id: promotionId });
  1784. }
  1785. });