order-taxes.e2e-spec.ts 13 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350
  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 path from 'path';
  14. import { afterAll, beforeAll, describe, expect, it } from 'vitest';
  15. import { initialData } from '../../../e2e-common/e2e-initial-data';
  16. import { TEST_SETUP_TIMEOUT_MS, testConfig } from '../../../e2e-common/test-config';
  17. import { testSuccessfulPaymentMethod } from './fixtures/test-payment-methods';
  18. import { FragmentOf, graphql, ResultOf } from './graphql/graphql-admin';
  19. import {
  20. getProductsWithVariantPricesDocument,
  21. updateChannelDocument,
  22. updateTaxRateDocument,
  23. } from './graphql/shared-definitions';
  24. import {
  25. addItemToOrderDocument,
  26. getActiveOrderWithPriceDataDocument,
  27. setBillingAddressDocument,
  28. setShippingAddressDocument,
  29. testOrderFragment,
  30. updatedOrderFragment,
  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. export const getTaxRateListDocument = graphql(`
  70. query GetTaxRateList($options: TaxRateListOptions) {
  71. taxRates(options: $options) {
  72. items {
  73. id
  74. name
  75. enabled
  76. value
  77. category {
  78. id
  79. name
  80. }
  81. zone {
  82. id
  83. name
  84. }
  85. }
  86. totalItems
  87. }
  88. }
  89. `);
  90. describe('Order taxes', () => {
  91. const { server, adminClient, shopClient } = createTestEnvironment({
  92. ...testConfig(),
  93. taxOptions: {
  94. taxZoneStrategy: new TestTaxZoneStrategy(),
  95. },
  96. paymentOptions: {
  97. paymentMethodHandlers: [testSuccessfulPaymentMethod],
  98. },
  99. });
  100. type OrderSuccessResult = FragmentOf<typeof updatedOrderFragment> | FragmentOf<typeof testOrderFragment>;
  101. const orderResultGuard: ErrorResultGuard<OrderSuccessResult> = createErrorResultGuard(
  102. input => !!input.lines,
  103. );
  104. type ActiveOrderWithPriceData = NonNullable<
  105. ResultOf<typeof getActiveOrderWithPriceDataDocument>['activeOrder']
  106. >;
  107. const activeOrderGuard: ErrorResultGuard<ActiveOrderWithPriceData> = createErrorResultGuard(
  108. input => !!input.taxSummary,
  109. );
  110. type TaxRateList = ResultOf<typeof getTaxRateListDocument>['taxRates'];
  111. const taxRatesGuard: ErrorResultGuard<TaxRateList> = createErrorResultGuard<TaxRateList>(
  112. input => !!input.items,
  113. );
  114. let products: ResultOf<typeof getProductsWithVariantPricesDocument>['products']['items'];
  115. beforeAll(async () => {
  116. await server.init({
  117. initialData: {
  118. ...initialData,
  119. paymentMethods: [
  120. {
  121. name: testSuccessfulPaymentMethod.code,
  122. handler: { code: testSuccessfulPaymentMethod.code, arguments: [] },
  123. },
  124. ],
  125. },
  126. productsCsvPath: path.join(__dirname, 'fixtures/e2e-products-order-taxes.csv'),
  127. customerCount: 2,
  128. });
  129. await adminClient.asSuperAdmin();
  130. const result = await adminClient.query(getProductsWithVariantPricesDocument);
  131. products = result.products.items;
  132. }, TEST_SETUP_TIMEOUT_MS);
  133. afterAll(async () => {
  134. await server.destroy();
  135. });
  136. describe('Channel.pricesIncludeTax = false', () => {
  137. beforeAll(async () => {
  138. await adminClient.query(updateChannelDocument, {
  139. input: {
  140. id: 'T_1',
  141. pricesIncludeTax: false,
  142. },
  143. });
  144. await shopClient.asAnonymousUser();
  145. });
  146. it('prices are correct', async () => {
  147. const variant = products.sort(sortById)[0].variants.sort(sortById)[0];
  148. await shopClient.query(addItemToOrderDocument, {
  149. productVariantId: variant.id,
  150. quantity: 2,
  151. });
  152. const { activeOrder } = await shopClient.query(getActiveOrderWithPriceDataDocument);
  153. expect(activeOrder?.totalWithTax).toBe(240);
  154. expect(activeOrder?.total).toBe(200);
  155. expect(activeOrder?.lines[0].taxRate).toBe(20);
  156. expect(activeOrder?.lines[0].linePrice).toBe(200);
  157. expect(activeOrder?.lines[0].lineTax).toBe(40);
  158. expect(activeOrder?.lines[0].linePriceWithTax).toBe(240);
  159. expect(activeOrder?.lines[0].unitPrice).toBe(100);
  160. expect(activeOrder?.lines[0].unitPriceWithTax).toBe(120);
  161. expect(activeOrder?.lines[0].taxLines).toEqual([
  162. {
  163. description: 'Standard Tax Europe',
  164. taxRate: 20,
  165. },
  166. ]);
  167. });
  168. });
  169. describe('Channel.pricesIncludeTax = true', () => {
  170. beforeAll(async () => {
  171. await adminClient.query(updateChannelDocument, {
  172. input: {
  173. id: 'T_1',
  174. pricesIncludeTax: true,
  175. },
  176. });
  177. await shopClient.asAnonymousUser();
  178. });
  179. it('prices are correct', async () => {
  180. const variant = products[0].variants[0];
  181. await shopClient.query(addItemToOrderDocument, {
  182. productVariantId: variant.id,
  183. quantity: 2,
  184. });
  185. const { activeOrder } = await shopClient.query(getActiveOrderWithPriceDataDocument);
  186. expect(activeOrder?.totalWithTax).toBe(200);
  187. expect(activeOrder?.total).toBe(167);
  188. expect(activeOrder?.lines[0].taxRate).toBe(20);
  189. expect(activeOrder?.lines[0].linePrice).toBe(167);
  190. expect(activeOrder?.lines[0].lineTax).toBe(33);
  191. expect(activeOrder?.lines[0].linePriceWithTax).toBe(200);
  192. expect(activeOrder?.lines[0].unitPrice).toBe(83);
  193. expect(activeOrder?.lines[0].unitPriceWithTax).toBe(100);
  194. expect(activeOrder?.lines[0].taxLines).toEqual([
  195. {
  196. description: 'Standard Tax Europe',
  197. taxRate: 20,
  198. },
  199. ]);
  200. });
  201. // https://github.com/vendure-ecommerce/vendure/issues/1216
  202. it('re-calculates OrderLine prices when shippingAddress causes activeTaxZone change', async () => {
  203. const { taxRates } = await adminClient.query(getTaxRateListDocument);
  204. taxRatesGuard.assertSuccess(taxRates);
  205. // Set the TaxRates to Asia to 0%
  206. const taxRatesAsia = taxRates.items.filter(tr => tr.name.includes('Asia'));
  207. for (const taxRate of taxRatesAsia) {
  208. await adminClient.query(updateTaxRateDocument, {
  209. input: {
  210. id: taxRate.id,
  211. value: 0,
  212. },
  213. });
  214. }
  215. await shopClient.query(setShippingAddressDocument, {
  216. input: {
  217. countryCode: 'CN',
  218. streetLine1: '123 Lugu St',
  219. city: 'Beijing',
  220. province: 'Beijing',
  221. postalCode: '12340',
  222. },
  223. });
  224. const { activeOrder } = await shopClient.query(getActiveOrderWithPriceDataDocument);
  225. expect(activeOrder?.totalWithTax).toBe(166);
  226. expect(activeOrder?.total).toBe(166);
  227. expect(activeOrder?.lines[0].taxRate).toBe(0);
  228. expect(activeOrder?.lines[0].linePrice).toBe(166);
  229. expect(activeOrder?.lines[0].lineTax).toBe(0);
  230. expect(activeOrder?.lines[0].linePriceWithTax).toBe(166);
  231. expect(activeOrder?.lines[0].unitPrice).toBe(83);
  232. expect(activeOrder?.lines[0].unitPriceWithTax).toBe(83);
  233. expect(activeOrder?.lines[0].taxLines).toEqual([
  234. {
  235. description: 'Standard Tax Asia',
  236. taxRate: 0,
  237. },
  238. ]);
  239. });
  240. // https://github.com/vendure-ecommerce/vendure/issues/1216
  241. it('re-calculates OrderLine prices when billingAddress causes activeTaxZone change', async () => {
  242. await shopClient.query(setBillingAddressDocument, {
  243. input: {
  244. countryCode: 'US',
  245. streetLine1: '123 Chad Street',
  246. city: 'Houston',
  247. province: 'Texas',
  248. postalCode: '12345',
  249. },
  250. });
  251. const { activeOrder } = await shopClient.query(getActiveOrderWithPriceDataDocument);
  252. expect(activeOrder?.totalWithTax).toBe(199);
  253. expect(activeOrder?.total).toBe(166);
  254. expect(activeOrder?.lines[0].taxRate).toBe(20);
  255. expect(activeOrder?.lines[0].linePrice).toBe(166);
  256. expect(activeOrder?.lines[0].lineTax).toBe(33);
  257. expect(activeOrder?.lines[0].linePriceWithTax).toBe(199);
  258. expect(activeOrder?.lines[0].unitPrice).toBe(83);
  259. expect(activeOrder?.lines[0].unitPriceWithTax).toBe(100);
  260. expect(activeOrder?.lines[0].taxLines).toEqual([
  261. {
  262. description: 'Standard Tax Americas',
  263. taxRate: 20,
  264. },
  265. ]);
  266. });
  267. });
  268. it('taxSummary works', async () => {
  269. await adminClient.query(updateChannelDocument, {
  270. input: {
  271. id: 'T_1',
  272. pricesIncludeTax: false,
  273. },
  274. });
  275. await shopClient.asAnonymousUser();
  276. await shopClient.query(addItemToOrderDocument, {
  277. productVariantId: products[0].variants[0].id,
  278. quantity: 2,
  279. });
  280. await shopClient.query(addItemToOrderDocument, {
  281. productVariantId: products[1].variants[0].id,
  282. quantity: 2,
  283. });
  284. await shopClient.query(addItemToOrderDocument, {
  285. productVariantId: products[2].variants[0].id,
  286. quantity: 2,
  287. });
  288. const { activeOrder } = await shopClient.query(getActiveOrderWithPriceDataDocument);
  289. expect(activeOrder?.taxSummary).toEqual([
  290. {
  291. description: 'Standard Tax Europe',
  292. taxRate: 20,
  293. taxBase: 200,
  294. taxTotal: 40,
  295. },
  296. {
  297. description: 'Reduced Tax Europe',
  298. taxRate: 10,
  299. taxBase: 200,
  300. taxTotal: 20,
  301. },
  302. {
  303. description: 'Zero Tax Europe',
  304. taxRate: 0,
  305. taxBase: 200,
  306. taxTotal: 0,
  307. },
  308. ]);
  309. // ensure that the summary total add up to the overall totals
  310. activeOrderGuard.assertSuccess(activeOrder);
  311. const taxSummaryBaseTotal = summate(activeOrder.taxSummary, 'taxBase');
  312. const taxSummaryTaxTotal = summate(activeOrder.taxSummary, 'taxTotal');
  313. expect(taxSummaryBaseTotal).toBe(activeOrder.total);
  314. expect(taxSummaryBaseTotal + taxSummaryTaxTotal).toBe(activeOrder.totalWithTax);
  315. });
  316. });