order-taxes.e2e-spec.ts 14 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377
  1. /* eslint-disable @typescript-eslint/no-non-null-assertion */
  2. import { summate } from '@vendure/common/lib/shared-utils';
  3. import {
  4. Channel,
  5. Injector,
  6. Order,
  7. RequestContext,
  8. TaxZoneStrategy,
  9. TransactionalConnection,
  10. Zone,
  11. } from '@vendure/core';
  12. import { createErrorResultGuard, createTestEnvironment, ErrorResultGuard } from '@vendure/testing';
  13. import gql from 'graphql-tag';
  14. import path from 'path';
  15. import { afterAll, beforeAll, describe, expect, it } from 'vitest';
  16. import { initialData } from '../../../e2e-common/e2e-initial-data';
  17. import { TEST_SETUP_TIMEOUT_MS, testConfig } from '../../../e2e-common/test-config';
  18. import { testSuccessfulPaymentMethod } from './fixtures/test-payment-methods';
  19. import * as Codegen from './graphql/generated-e2e-admin-types';
  20. import * as CodegenShop from './graphql/generated-e2e-shop-types';
  21. import {
  22. GET_PRODUCTS_WITH_VARIANT_PRICES,
  23. UPDATE_CHANNEL,
  24. UPDATE_TAX_RATE,
  25. } from './graphql/shared-definitions';
  26. import {
  27. ADD_ITEM_TO_ORDER,
  28. GET_ACTIVE_ORDER_WITH_PRICE_DATA,
  29. SET_BILLING_ADDRESS,
  30. SET_SHIPPING_ADDRESS,
  31. } from './graphql/shop-definitions';
  32. import { sortById } from './utils/test-order-utils';
  33. /**
  34. * Determines active tax zone based on:
  35. *
  36. * 1. billingAddress country, if set
  37. * 2. else shippingAddress country, is set
  38. * 3. else channel default tax zone.
  39. */
  40. class TestTaxZoneStrategy implements TaxZoneStrategy {
  41. private connection: TransactionalConnection;
  42. init(injector: Injector): void | Promise<void> {
  43. this.connection = injector.get(TransactionalConnection);
  44. }
  45. async determineTaxZone(
  46. ctx: RequestContext,
  47. zones: Zone[],
  48. channel: Channel,
  49. order?: Order,
  50. ): Promise<Zone> {
  51. if (!order?.billingAddress?.countryCode && !order?.shippingAddress?.countryCode) {
  52. return channel.defaultTaxZone;
  53. }
  54. const countryCode = order?.billingAddress?.countryCode || order?.shippingAddress?.countryCode;
  55. const zoneForCountryCode = await this.getZoneForCountryCode(ctx, countryCode);
  56. return zoneForCountryCode ?? channel.defaultTaxZone;
  57. }
  58. private getZoneForCountryCode(ctx: RequestContext, countryCode?: string): Promise<Zone | null> {
  59. return this.connection
  60. .getRepository(ctx, Zone)
  61. .createQueryBuilder('zone')
  62. .leftJoin('zone.members', 'country')
  63. .where('country.code = :countryCode', {
  64. countryCode,
  65. })
  66. .getOne();
  67. }
  68. }
  69. describe('Order taxes', () => {
  70. const { server, adminClient, shopClient } = createTestEnvironment({
  71. ...testConfig(),
  72. taxOptions: {
  73. taxZoneStrategy: new TestTaxZoneStrategy(),
  74. },
  75. paymentOptions: {
  76. paymentMethodHandlers: [testSuccessfulPaymentMethod],
  77. },
  78. });
  79. type OrderSuccessResult = CodegenShop.UpdatedOrderFragment | CodegenShop.TestOrderFragmentFragment;
  80. const orderResultGuard: ErrorResultGuard<OrderSuccessResult> = createErrorResultGuard(
  81. input => !!input.lines,
  82. );
  83. let products: Codegen.GetProductsWithVariantPricesQuery['products']['items'];
  84. beforeAll(async () => {
  85. await server.init({
  86. initialData: {
  87. ...initialData,
  88. paymentMethods: [
  89. {
  90. name: testSuccessfulPaymentMethod.code,
  91. handler: { code: testSuccessfulPaymentMethod.code, arguments: [] },
  92. },
  93. ],
  94. },
  95. productsCsvPath: path.join(__dirname, 'fixtures/e2e-products-order-taxes.csv'),
  96. customerCount: 2,
  97. });
  98. await adminClient.asSuperAdmin();
  99. const result = await adminClient.query<Codegen.GetProductsWithVariantPricesQuery>(
  100. GET_PRODUCTS_WITH_VARIANT_PRICES,
  101. );
  102. products = result.products.items;
  103. }, TEST_SETUP_TIMEOUT_MS);
  104. afterAll(async () => {
  105. await server.destroy();
  106. });
  107. describe('Channel.pricesIncludeTax = false', () => {
  108. beforeAll(async () => {
  109. await adminClient.query<Codegen.UpdateChannelMutation, Codegen.UpdateChannelMutationVariables>(
  110. UPDATE_CHANNEL,
  111. {
  112. input: {
  113. id: 'T_1',
  114. pricesIncludeTax: false,
  115. },
  116. },
  117. );
  118. await shopClient.asAnonymousUser();
  119. });
  120. it('prices are correct', async () => {
  121. const variant = products.sort(sortById)[0].variants.sort(sortById)[0];
  122. await shopClient.query<
  123. CodegenShop.AddItemToOrderMutation,
  124. CodegenShop.AddItemToOrderMutationVariables
  125. >(ADD_ITEM_TO_ORDER, {
  126. productVariantId: variant.id,
  127. quantity: 2,
  128. });
  129. const { activeOrder } = await shopClient.query<CodegenShop.GetActiveOrderWithPriceDataQuery>(
  130. GET_ACTIVE_ORDER_WITH_PRICE_DATA,
  131. );
  132. expect(activeOrder?.totalWithTax).toBe(240);
  133. expect(activeOrder?.total).toBe(200);
  134. expect(activeOrder?.lines[0].taxRate).toBe(20);
  135. expect(activeOrder?.lines[0].linePrice).toBe(200);
  136. expect(activeOrder?.lines[0].lineTax).toBe(40);
  137. expect(activeOrder?.lines[0].linePriceWithTax).toBe(240);
  138. expect(activeOrder?.lines[0].unitPrice).toBe(100);
  139. expect(activeOrder?.lines[0].unitPriceWithTax).toBe(120);
  140. expect(activeOrder?.lines[0].taxLines).toEqual([
  141. {
  142. description: 'Standard Tax Europe',
  143. taxRate: 20,
  144. },
  145. ]);
  146. });
  147. });
  148. describe('Channel.pricesIncludeTax = true', () => {
  149. beforeAll(async () => {
  150. await adminClient.query<Codegen.UpdateChannelMutation, Codegen.UpdateChannelMutationVariables>(
  151. UPDATE_CHANNEL,
  152. {
  153. input: {
  154. id: 'T_1',
  155. pricesIncludeTax: true,
  156. },
  157. },
  158. );
  159. await shopClient.asAnonymousUser();
  160. });
  161. it('prices are correct', async () => {
  162. const variant = products[0].variants[0];
  163. await shopClient.query<
  164. CodegenShop.AddItemToOrderMutation,
  165. CodegenShop.AddItemToOrderMutationVariables
  166. >(ADD_ITEM_TO_ORDER, {
  167. productVariantId: variant.id,
  168. quantity: 2,
  169. });
  170. const { activeOrder } = await shopClient.query<CodegenShop.GetActiveOrderWithPriceDataQuery>(
  171. GET_ACTIVE_ORDER_WITH_PRICE_DATA,
  172. );
  173. expect(activeOrder?.totalWithTax).toBe(200);
  174. expect(activeOrder?.total).toBe(167);
  175. expect(activeOrder?.lines[0].taxRate).toBe(20);
  176. expect(activeOrder?.lines[0].linePrice).toBe(167);
  177. expect(activeOrder?.lines[0].lineTax).toBe(33);
  178. expect(activeOrder?.lines[0].linePriceWithTax).toBe(200);
  179. expect(activeOrder?.lines[0].unitPrice).toBe(83);
  180. expect(activeOrder?.lines[0].unitPriceWithTax).toBe(100);
  181. expect(activeOrder?.lines[0].taxLines).toEqual([
  182. {
  183. description: 'Standard Tax Europe',
  184. taxRate: 20,
  185. },
  186. ]);
  187. });
  188. // https://github.com/vendurehq/vendure/issues/1216
  189. it('re-calculates OrderLine prices when shippingAddress causes activeTaxZone change', async () => {
  190. const { taxRates } = await adminClient.query<Codegen.GetTaxRateListQuery>(GET_TAX_RATE_LIST);
  191. // Set the TaxRates to Asia to 0%
  192. const taxRatesAsia = taxRates.items.filter(tr => tr.name.includes('Asia'));
  193. for (const taxRate of taxRatesAsia) {
  194. await adminClient.query<
  195. Codegen.UpdateTaxRateMutation,
  196. Codegen.UpdateTaxRateMutationVariables
  197. >(UPDATE_TAX_RATE, {
  198. input: {
  199. id: taxRate.id,
  200. value: 0,
  201. },
  202. });
  203. }
  204. await shopClient.query<
  205. CodegenShop.SetShippingAddressMutation,
  206. CodegenShop.SetShippingAddressMutationVariables
  207. >(SET_SHIPPING_ADDRESS, {
  208. input: {
  209. countryCode: 'CN',
  210. streetLine1: '123 Lugu St',
  211. city: 'Beijing',
  212. province: 'Beijing',
  213. postalCode: '12340',
  214. },
  215. });
  216. const { activeOrder } = await shopClient.query<CodegenShop.GetActiveOrderWithPriceDataQuery>(
  217. GET_ACTIVE_ORDER_WITH_PRICE_DATA,
  218. );
  219. expect(activeOrder?.totalWithTax).toBe(166);
  220. expect(activeOrder?.total).toBe(166);
  221. expect(activeOrder?.lines[0].taxRate).toBe(0);
  222. expect(activeOrder?.lines[0].linePrice).toBe(166);
  223. expect(activeOrder?.lines[0].lineTax).toBe(0);
  224. expect(activeOrder?.lines[0].linePriceWithTax).toBe(166);
  225. expect(activeOrder?.lines[0].unitPrice).toBe(83);
  226. expect(activeOrder?.lines[0].unitPriceWithTax).toBe(83);
  227. expect(activeOrder?.lines[0].taxLines).toEqual([
  228. {
  229. description: 'Standard Tax Asia',
  230. taxRate: 0,
  231. },
  232. ]);
  233. });
  234. // https://github.com/vendurehq/vendure/issues/1216
  235. it('re-calculates OrderLine prices when billingAddress causes activeTaxZone change', async () => {
  236. await shopClient.query<
  237. CodegenShop.SetBillingAddressMutation,
  238. CodegenShop.SetBillingAddressMutationVariables
  239. >(SET_BILLING_ADDRESS, {
  240. input: {
  241. countryCode: 'US',
  242. streetLine1: '123 Chad Street',
  243. city: 'Houston',
  244. province: 'Texas',
  245. postalCode: '12345',
  246. },
  247. });
  248. const { activeOrder } = await shopClient.query<CodegenShop.GetActiveOrderWithPriceDataQuery>(
  249. GET_ACTIVE_ORDER_WITH_PRICE_DATA,
  250. );
  251. expect(activeOrder?.totalWithTax).toBe(199);
  252. expect(activeOrder?.total).toBe(166);
  253. expect(activeOrder?.lines[0].taxRate).toBe(20);
  254. expect(activeOrder?.lines[0].linePrice).toBe(166);
  255. expect(activeOrder?.lines[0].lineTax).toBe(33);
  256. expect(activeOrder?.lines[0].linePriceWithTax).toBe(199);
  257. expect(activeOrder?.lines[0].unitPrice).toBe(83);
  258. expect(activeOrder?.lines[0].unitPriceWithTax).toBe(100);
  259. expect(activeOrder?.lines[0].taxLines).toEqual([
  260. {
  261. description: 'Standard Tax Americas',
  262. taxRate: 20,
  263. },
  264. ]);
  265. });
  266. });
  267. it('taxSummary works', async () => {
  268. await adminClient.query<Codegen.UpdateChannelMutation, Codegen.UpdateChannelMutationVariables>(
  269. UPDATE_CHANNEL,
  270. {
  271. input: {
  272. id: 'T_1',
  273. pricesIncludeTax: false,
  274. },
  275. },
  276. );
  277. await shopClient.asAnonymousUser();
  278. await shopClient.query<
  279. CodegenShop.AddItemToOrderMutation,
  280. CodegenShop.AddItemToOrderMutationVariables
  281. >(ADD_ITEM_TO_ORDER, {
  282. productVariantId: products[0].variants[0].id,
  283. quantity: 2,
  284. });
  285. await shopClient.query<
  286. CodegenShop.AddItemToOrderMutation,
  287. CodegenShop.AddItemToOrderMutationVariables
  288. >(ADD_ITEM_TO_ORDER, {
  289. productVariantId: products[1].variants[0].id,
  290. quantity: 2,
  291. });
  292. await shopClient.query<
  293. CodegenShop.AddItemToOrderMutation,
  294. CodegenShop.AddItemToOrderMutationVariables
  295. >(ADD_ITEM_TO_ORDER, {
  296. productVariantId: products[2].variants[0].id,
  297. quantity: 2,
  298. });
  299. const { activeOrder } = await shopClient.query<CodegenShop.GetActiveOrderWithPriceDataQuery>(
  300. GET_ACTIVE_ORDER_WITH_PRICE_DATA,
  301. );
  302. expect(activeOrder?.taxSummary).toEqual([
  303. {
  304. description: 'Standard Tax Europe',
  305. taxRate: 20,
  306. taxBase: 200,
  307. taxTotal: 40,
  308. },
  309. {
  310. description: 'Reduced Tax Europe',
  311. taxRate: 10,
  312. taxBase: 200,
  313. taxTotal: 20,
  314. },
  315. {
  316. description: 'Zero Tax Europe',
  317. taxRate: 0,
  318. taxBase: 200,
  319. taxTotal: 0,
  320. },
  321. ]);
  322. // ensure that the summary total add up to the overall totals
  323. const taxSummaryBaseTotal = summate(activeOrder!.taxSummary, 'taxBase');
  324. const taxSummaryTaxTotal = summate(activeOrder!.taxSummary, 'taxTotal');
  325. expect(taxSummaryBaseTotal).toBe(activeOrder?.total);
  326. expect(taxSummaryBaseTotal + taxSummaryTaxTotal).toBe(activeOrder?.totalWithTax);
  327. });
  328. });
  329. export const GET_TAX_RATE_LIST = gql`
  330. query GetTaxRateList($options: TaxRateListOptions) {
  331. taxRates(options: $options) {
  332. items {
  333. id
  334. name
  335. enabled
  336. value
  337. category {
  338. id
  339. name
  340. }
  341. zone {
  342. id
  343. name
  344. }
  345. }
  346. totalItems
  347. }
  348. }
  349. `;