stripe-payment.e2e-spec.ts 22 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541
  1. /* eslint-disable @typescript-eslint/no-non-null-assertion */
  2. import { EntityHydrator, mergeConfig } from '@vendure/core';
  3. import {
  4. CreateProductMutation,
  5. CreateProductMutationVariables,
  6. CreateProductVariantsMutation,
  7. CreateProductVariantsMutationVariables,
  8. } from '@vendure/core/e2e/graphql/generated-e2e-admin-types';
  9. import { CREATE_PRODUCT, CREATE_PRODUCT_VARIANTS } from '@vendure/core/e2e/graphql/shared-definitions';
  10. import { createTestEnvironment, E2E_DEFAULT_CHANNEL_TOKEN } from '@vendure/testing';
  11. import gql from 'graphql-tag';
  12. import nock from 'nock';
  13. import fetch from 'node-fetch';
  14. import path from 'path';
  15. import { Stripe } from 'stripe';
  16. import { afterAll, beforeAll, describe, expect, it } from 'vitest';
  17. import { initialData } from '../../../e2e-common/e2e-initial-data';
  18. import { testConfig, TEST_SETUP_TIMEOUT_MS } from '../../../e2e-common/test-config';
  19. import { StripePlugin } from '../src/stripe';
  20. import { stripePaymentMethodHandler } from '../src/stripe/stripe.handler';
  21. import { CREATE_CHANNEL, CREATE_PAYMENT_METHOD, GET_CUSTOMER_LIST } from './graphql/admin-queries';
  22. import {
  23. CreateChannelMutation,
  24. CreateChannelMutationVariables,
  25. CreatePaymentMethodMutation,
  26. CreatePaymentMethodMutationVariables,
  27. CurrencyCode,
  28. GetCustomerListQuery,
  29. GetCustomerListQueryVariables,
  30. LanguageCode,
  31. } from './graphql/generated-admin-types';
  32. import {
  33. AddItemToOrderMutation,
  34. AddItemToOrderMutationVariables,
  35. GetActiveOrderQuery,
  36. TestOrderFragmentFragment,
  37. } from './graphql/generated-shop-types';
  38. import { ADD_ITEM_TO_ORDER, GET_ACTIVE_ORDER } from './graphql/shop-queries';
  39. import { setShipping } from './payment-helpers';
  40. export const CREATE_STRIPE_PAYMENT_INTENT = gql`
  41. mutation createStripePaymentIntent {
  42. createStripePaymentIntent
  43. }
  44. `;
  45. describe('Stripe payments', () => {
  46. const devConfig = mergeConfig(testConfig(), {
  47. plugins: [
  48. StripePlugin.init({
  49. storeCustomersInStripe: true,
  50. }),
  51. ],
  52. });
  53. const { shopClient, adminClient, server } = createTestEnvironment(devConfig);
  54. let started = false;
  55. let customers: GetCustomerListQuery['customers']['items'];
  56. let order: TestOrderFragmentFragment;
  57. let serverPort: number;
  58. beforeAll(async () => {
  59. serverPort = devConfig.apiOptions.port;
  60. await server.init({
  61. initialData,
  62. productsCsvPath: path.join(__dirname, 'fixtures/e2e-products-minimal.csv'),
  63. customerCount: 2,
  64. });
  65. started = true;
  66. await adminClient.asSuperAdmin();
  67. ({
  68. customers: { items: customers },
  69. } = await adminClient.query<GetCustomerListQuery, GetCustomerListQueryVariables>(GET_CUSTOMER_LIST, {
  70. options: {
  71. take: 2,
  72. },
  73. }));
  74. }, TEST_SETUP_TIMEOUT_MS);
  75. afterAll(async () => {
  76. await server.destroy();
  77. });
  78. it('Should start successfully', () => {
  79. expect(started).toEqual(true);
  80. expect(customers).toHaveLength(2);
  81. });
  82. it('Should prepare an order', async () => {
  83. await shopClient.asUserWithCredentials(customers[0].emailAddress, 'test');
  84. const { addItemToOrder } = await shopClient.query<
  85. AddItemToOrderMutation,
  86. AddItemToOrderMutationVariables
  87. >(ADD_ITEM_TO_ORDER, {
  88. productVariantId: 'T_1',
  89. quantity: 2,
  90. });
  91. order = addItemToOrder as TestOrderFragmentFragment;
  92. expect(order.code).toBeDefined();
  93. });
  94. it('Should add a Stripe paymentMethod', async () => {
  95. const { createPaymentMethod } = await adminClient.query<
  96. CreatePaymentMethodMutation,
  97. CreatePaymentMethodMutationVariables
  98. >(CREATE_PAYMENT_METHOD, {
  99. input: {
  100. code: `stripe-payment-${E2E_DEFAULT_CHANNEL_TOKEN}`,
  101. translations: [
  102. {
  103. name: 'Stripe payment test',
  104. description: 'This is a Stripe test payment method',
  105. languageCode: LanguageCode.en,
  106. },
  107. ],
  108. enabled: true,
  109. handler: {
  110. code: stripePaymentMethodHandler.code,
  111. arguments: [
  112. { name: 'apiKey', value: 'test-api-key' },
  113. { name: 'webhookSecret', value: 'test-signing-secret' },
  114. ],
  115. },
  116. },
  117. });
  118. expect(createPaymentMethod.code).toBe(`stripe-payment-${E2E_DEFAULT_CHANNEL_TOKEN}`);
  119. await shopClient.asUserWithCredentials(customers[0].emailAddress, 'test');
  120. await setShipping(shopClient);
  121. });
  122. it('if no customer id exists, makes a call to create', async () => {
  123. let createCustomerPayload: { name: string; email: string } | undefined;
  124. const emptyList = { data: [] };
  125. nock('https://api.stripe.com/')
  126. .get(/\/v1\/customers.*/)
  127. .reply(200, emptyList);
  128. nock('https://api.stripe.com/')
  129. .post('/v1/customers', body => {
  130. createCustomerPayload = body;
  131. return true;
  132. })
  133. .reply(201, {
  134. id: 'new-customer-id',
  135. });
  136. nock('https://api.stripe.com/').post('/v1/payment_intents').reply(200, {
  137. client_secret: 'test-client-secret',
  138. });
  139. const { createStripePaymentIntent } = await shopClient.query(CREATE_STRIPE_PAYMENT_INTENT);
  140. expect(createCustomerPayload).toEqual({
  141. email: 'hayden.zieme12@hotmail.com',
  142. name: 'Hayden Zieme',
  143. });
  144. });
  145. it('should send correct payload to create payment intent', async () => {
  146. let createPaymentIntentPayload: any;
  147. const { activeOrder } = await shopClient.query<GetActiveOrderQuery>(GET_ACTIVE_ORDER);
  148. nock('https://api.stripe.com/')
  149. .post('/v1/payment_intents', body => {
  150. createPaymentIntentPayload = body;
  151. return true;
  152. })
  153. .reply(200, {
  154. client_secret: 'test-client-secret',
  155. });
  156. const { createStripePaymentIntent } = await shopClient.query(CREATE_STRIPE_PAYMENT_INTENT);
  157. expect(createPaymentIntentPayload).toEqual({
  158. amount: activeOrder?.totalWithTax.toString(),
  159. currency: activeOrder?.currencyCode?.toLowerCase(),
  160. customer: 'new-customer-id',
  161. 'automatic_payment_methods[enabled]': 'true',
  162. 'metadata[channelToken]': E2E_DEFAULT_CHANNEL_TOKEN,
  163. 'metadata[orderId]': '1',
  164. 'metadata[orderCode]': activeOrder?.code,
  165. });
  166. expect(createStripePaymentIntent).toEqual('test-client-secret');
  167. });
  168. // https://github.com/vendure-ecommerce/vendure/issues/1935
  169. it('should attach metadata to stripe payment intent', async () => {
  170. StripePlugin.options.metadata = async (injector, ctx, currentOrder) => {
  171. const hydrator = injector.get(EntityHydrator);
  172. await hydrator.hydrate(ctx, currentOrder, { relations: ['customer'] });
  173. return {
  174. customerEmail: currentOrder.customer?.emailAddress ?? 'demo',
  175. };
  176. };
  177. let createPaymentIntentPayload: any;
  178. const { activeOrder } = await shopClient.query<GetActiveOrderQuery>(GET_ACTIVE_ORDER);
  179. nock('https://api.stripe.com/')
  180. .post('/v1/payment_intents', body => {
  181. createPaymentIntentPayload = body;
  182. return true;
  183. })
  184. .reply(200, {
  185. client_secret: 'test-client-secret',
  186. });
  187. const { createStripePaymentIntent } = await shopClient.query(CREATE_STRIPE_PAYMENT_INTENT);
  188. expect(createPaymentIntentPayload).toEqual({
  189. amount: activeOrder?.totalWithTax.toString(),
  190. currency: activeOrder?.currencyCode?.toLowerCase(),
  191. customer: 'new-customer-id',
  192. 'automatic_payment_methods[enabled]': 'true',
  193. 'metadata[channelToken]': E2E_DEFAULT_CHANNEL_TOKEN,
  194. 'metadata[orderId]': '1',
  195. 'metadata[orderCode]': activeOrder?.code,
  196. 'metadata[customerEmail]': customers[0].emailAddress,
  197. });
  198. expect(createStripePaymentIntent).toEqual('test-client-secret');
  199. StripePlugin.options.metadata = undefined;
  200. });
  201. // https://github.com/vendure-ecommerce/vendure/issues/2412
  202. it('should attach additional params to payment intent using paymentIntentCreateParams', async () => {
  203. StripePlugin.options.paymentIntentCreateParams = async (injector, ctx, currentOrder) => {
  204. const hydrator = injector.get(EntityHydrator);
  205. await hydrator.hydrate(ctx, currentOrder, { relations: ['customer'] });
  206. return {
  207. description: `Order #${currentOrder.code} for ${currentOrder.customer!.emailAddress}`,
  208. };
  209. };
  210. let createPaymentIntentPayload: any;
  211. const { activeOrder } = await shopClient.query<GetActiveOrderQuery>(GET_ACTIVE_ORDER);
  212. nock('https://api.stripe.com/')
  213. .post('/v1/payment_intents', body => {
  214. createPaymentIntentPayload = body;
  215. return true;
  216. })
  217. .reply(200, {
  218. client_secret: 'test-client-secret',
  219. });
  220. const { createStripePaymentIntent } = await shopClient.query(CREATE_STRIPE_PAYMENT_INTENT);
  221. expect(createPaymentIntentPayload).toEqual({
  222. amount: activeOrder?.totalWithTax.toString(),
  223. currency: activeOrder?.currencyCode?.toLowerCase(),
  224. customer: 'new-customer-id',
  225. description: `Order #${activeOrder!.code} for ${activeOrder!.customer!.emailAddress}`,
  226. 'automatic_payment_methods[enabled]': 'true',
  227. 'metadata[channelToken]': E2E_DEFAULT_CHANNEL_TOKEN,
  228. 'metadata[orderId]': '1',
  229. 'metadata[orderCode]': activeOrder?.code,
  230. });
  231. expect(createStripePaymentIntent).toEqual('test-client-secret');
  232. StripePlugin.options.paymentIntentCreateParams = undefined;
  233. });
  234. // https://github.com/vendure-ecommerce/vendure/issues/3183
  235. it('should attach additional options to payment intent using requestOptions', async () => {
  236. StripePlugin.options.requestOptions = async (injector, ctx, currentOrder) => {
  237. return {
  238. stripeAccount: 'acct_connected',
  239. };
  240. };
  241. let connectedAccountHeader: any;
  242. let createPaymentIntentPayload: any;
  243. const { activeOrder } = await shopClient.query<GetActiveOrderQuery>(GET_ACTIVE_ORDER);
  244. nock('https://api.stripe.com/', {
  245. reqheaders: {
  246. 'Stripe-Account': headerValue => {
  247. connectedAccountHeader = headerValue;
  248. return true;
  249. },
  250. },
  251. })
  252. .post('/v1/payment_intents', body => {
  253. createPaymentIntentPayload = body;
  254. return true;
  255. })
  256. .reply(200, {
  257. client_secret: 'test-client-secret',
  258. });
  259. const { createStripePaymentIntent } = await shopClient.query(CREATE_STRIPE_PAYMENT_INTENT);
  260. expect(createPaymentIntentPayload).toEqual({
  261. amount: activeOrder?.totalWithTax.toString(),
  262. currency: activeOrder?.currencyCode?.toLowerCase(),
  263. customer: 'new-customer-id',
  264. 'automatic_payment_methods[enabled]': 'true',
  265. 'metadata[channelToken]': E2E_DEFAULT_CHANNEL_TOKEN,
  266. 'metadata[orderId]': '1',
  267. 'metadata[orderCode]': activeOrder?.code,
  268. });
  269. expect(connectedAccountHeader).toEqual('acct_connected');
  270. expect(createStripePaymentIntent).toEqual('test-client-secret');
  271. StripePlugin.options.paymentIntentCreateParams = undefined;
  272. });
  273. // https://github.com/vendure-ecommerce/vendure/issues/2412
  274. it('should attach additional params to customer using customerCreateParams', async () => {
  275. StripePlugin.options.customerCreateParams = async (injector, ctx, currentOrder) => {
  276. const hydrator = injector.get(EntityHydrator);
  277. await hydrator.hydrate(ctx, currentOrder, { relations: ['customer'] });
  278. return {
  279. description: `Description for ${currentOrder.customer!.emailAddress}`,
  280. phone: '12345',
  281. };
  282. };
  283. await shopClient.asUserWithCredentials(customers[1].emailAddress, 'test');
  284. const { addItemToOrder } = await shopClient.query<
  285. AddItemToOrderMutation,
  286. AddItemToOrderMutationVariables
  287. >(ADD_ITEM_TO_ORDER, {
  288. productVariantId: 'T_1',
  289. quantity: 2,
  290. });
  291. order = addItemToOrder as TestOrderFragmentFragment;
  292. let createCustomerPayload: { name: string; email: string } | undefined;
  293. const emptyList = { data: [] };
  294. nock('https://api.stripe.com/')
  295. .get(/\/v1\/customers.*/)
  296. .reply(200, emptyList);
  297. nock('https://api.stripe.com/')
  298. .post('/v1/customers', body => {
  299. createCustomerPayload = body;
  300. return true;
  301. })
  302. .reply(201, {
  303. id: 'new-customer-id',
  304. });
  305. nock('https://api.stripe.com/').post('/v1/payment_intents').reply(200, {
  306. client_secret: 'test-client-secret',
  307. });
  308. const { activeOrder } = await shopClient.query<GetActiveOrderQuery>(GET_ACTIVE_ORDER);
  309. await shopClient.query(CREATE_STRIPE_PAYMENT_INTENT);
  310. expect(createCustomerPayload).toEqual({
  311. email: 'trevor_donnelly96@hotmail.com',
  312. name: 'Trevor Donnelly',
  313. description: `Description for ${activeOrder!.customer!.emailAddress}`,
  314. phone: '12345',
  315. });
  316. });
  317. // https://github.com/vendure-ecommerce/vendure/issues/2450
  318. it('Should not crash on signature validation failure', async () => {
  319. const MOCKED_WEBHOOK_PAYLOAD = {
  320. id: 'evt_0',
  321. object: 'event',
  322. api_version: '2022-11-15',
  323. data: {
  324. object: {
  325. id: 'pi_0',
  326. currency: 'usd',
  327. status: 'succeeded',
  328. },
  329. },
  330. livemode: false,
  331. pending_webhooks: 1,
  332. request: {
  333. id: 'req_0',
  334. idempotency_key: '00000000-0000-0000-0000-000000000000',
  335. },
  336. type: 'payment_intent.succeeded',
  337. };
  338. const payloadString = JSON.stringify(MOCKED_WEBHOOK_PAYLOAD, null, 2);
  339. const result = await fetch(`http://localhost:${serverPort}/payments/stripe`, {
  340. method: 'post',
  341. body: payloadString,
  342. headers: { 'Content-Type': 'application/json' },
  343. });
  344. // We didn't provided any signatures, it should result in a 400 - Bad request
  345. expect(result.status).toEqual(400);
  346. });
  347. // TODO: Contribution welcome: test webhook handling and order settlement
  348. // https://github.com/vendure-ecommerce/vendure/issues/2450
  349. it("Should validate the webhook's signature properly", async () => {
  350. await shopClient.asUserWithCredentials(customers[0].emailAddress, 'test');
  351. const { activeOrder } = await shopClient.query<GetActiveOrderQuery>(GET_ACTIVE_ORDER);
  352. order = activeOrder!;
  353. const MOCKED_WEBHOOK_PAYLOAD = {
  354. id: 'evt_0',
  355. object: 'event',
  356. api_version: '2022-11-15',
  357. data: {
  358. object: {
  359. id: 'pi_0',
  360. currency: 'usd',
  361. metadata: {
  362. orderCode: order.code,
  363. orderId: parseInt(order.id.replace('T_', ''), 10),
  364. channelToken: E2E_DEFAULT_CHANNEL_TOKEN,
  365. },
  366. amount_received: order.totalWithTax,
  367. status: 'succeeded',
  368. },
  369. },
  370. livemode: false,
  371. pending_webhooks: 1,
  372. request: {
  373. id: 'req_0',
  374. idempotency_key: '00000000-0000-0000-0000-000000000000',
  375. },
  376. type: 'payment_intent.succeeded',
  377. };
  378. const payloadString = JSON.stringify(MOCKED_WEBHOOK_PAYLOAD, null, 2);
  379. const stripeWebhooks = new Stripe('test-api-secret', { apiVersion: '2023-08-16' }).webhooks;
  380. const header = stripeWebhooks.generateTestHeaderString({
  381. payload: payloadString,
  382. secret: 'test-signing-secret',
  383. });
  384. const event = stripeWebhooks.constructEvent(payloadString, header, 'test-signing-secret');
  385. expect(event.id).to.equal(MOCKED_WEBHOOK_PAYLOAD.id);
  386. await setShipping(shopClient);
  387. // Due to the `this.orderService.transitionToState(...)` fails with the internal lookup by id,
  388. // we need to put the order into `ArrangingPayment` state manually before calling the webhook handler.
  389. // const transitionResult = await adminClient.query(TRANSITION_TO_ARRANGING_PAYMENT, { id: order.id });
  390. // expect(transitionResult.transitionOrderToState.__typename).toBe('Order')
  391. const result = await fetch(`http://localhost:${serverPort}/payments/stripe`, {
  392. method: 'post',
  393. body: payloadString,
  394. headers: { 'Content-Type': 'application/json', 'Stripe-Signature': header },
  395. });
  396. // I would expect to the status to be 200, but at the moment either the
  397. // `orderService.transitionToState()` or the `orderService.addPaymentToOrder()`
  398. // throws an error of 'error.entity-with-id-not-found'
  399. expect(result.status).toEqual(200);
  400. });
  401. // https://github.com/vendure-ecommerce/vendure/issues/1630
  402. describe('currencies with no fractional units', () => {
  403. let japanProductId: string;
  404. beforeAll(async () => {
  405. const JAPAN_CHANNEL_TOKEN = 'japan-channel-token';
  406. const { createChannel } = await adminClient.query<
  407. CreateChannelMutation,
  408. CreateChannelMutationVariables
  409. >(CREATE_CHANNEL, {
  410. input: {
  411. code: 'japan-channel',
  412. currencyCode: CurrencyCode.JPY,
  413. token: JAPAN_CHANNEL_TOKEN,
  414. defaultLanguageCode: LanguageCode.en,
  415. defaultShippingZoneId: 'T_1',
  416. defaultTaxZoneId: 'T_1',
  417. pricesIncludeTax: true,
  418. },
  419. });
  420. adminClient.setChannelToken(JAPAN_CHANNEL_TOKEN);
  421. shopClient.setChannelToken(JAPAN_CHANNEL_TOKEN);
  422. const { createProduct } = await adminClient.query<
  423. CreateProductMutation,
  424. CreateProductMutationVariables
  425. >(CREATE_PRODUCT, {
  426. input: {
  427. translations: [
  428. {
  429. languageCode: LanguageCode.en,
  430. name: 'Channel Product',
  431. slug: 'channel-product',
  432. description: 'Channel product',
  433. },
  434. ],
  435. },
  436. });
  437. const { createProductVariants } = await adminClient.query<
  438. CreateProductVariantsMutation,
  439. CreateProductVariantsMutationVariables
  440. >(CREATE_PRODUCT_VARIANTS, {
  441. input: [
  442. {
  443. productId: createProduct.id,
  444. sku: 'PV1',
  445. optionIds: [],
  446. price: 5000,
  447. stockOnHand: 100,
  448. translations: [{ languageCode: LanguageCode.en, name: 'Variant 1' }],
  449. },
  450. ],
  451. });
  452. japanProductId = createProductVariants[0]!.id;
  453. // Create a payment method for the Japan channel
  454. await adminClient.query<CreatePaymentMethodMutation, CreatePaymentMethodMutationVariables>(
  455. CREATE_PAYMENT_METHOD,
  456. {
  457. input: {
  458. code: `stripe-payment-${E2E_DEFAULT_CHANNEL_TOKEN}`,
  459. translations: [
  460. {
  461. name: 'Stripe payment test',
  462. description: 'This is a Stripe test payment method',
  463. languageCode: LanguageCode.en,
  464. },
  465. ],
  466. enabled: true,
  467. handler: {
  468. code: stripePaymentMethodHandler.code,
  469. arguments: [
  470. { name: 'apiKey', value: 'test-api-key' },
  471. { name: 'webhookSecret', value: 'test-signing-secret' },
  472. ],
  473. },
  474. },
  475. },
  476. );
  477. });
  478. it('prepares order', async () => {
  479. await shopClient.asUserWithCredentials(customers[0].emailAddress, 'test');
  480. const { addItemToOrder } = await shopClient.query<
  481. AddItemToOrderMutation,
  482. AddItemToOrderMutationVariables
  483. >(ADD_ITEM_TO_ORDER, {
  484. productVariantId: japanProductId,
  485. quantity: 1,
  486. });
  487. expect((addItemToOrder as any).totalWithTax).toBe(5000);
  488. });
  489. it('sends correct amount when creating payment intent', async () => {
  490. let createPaymentIntentPayload: any;
  491. const { activeOrder } = await shopClient.query<GetActiveOrderQuery>(GET_ACTIVE_ORDER);
  492. nock('https://api.stripe.com/')
  493. .post('/v1/payment_intents', body => {
  494. createPaymentIntentPayload = body;
  495. return true;
  496. })
  497. .reply(200, {
  498. client_secret: 'test-client-secret',
  499. });
  500. const { createStripePaymentIntent } = await shopClient.query(CREATE_STRIPE_PAYMENT_INTENT);
  501. expect(createPaymentIntentPayload.amount).toBe((activeOrder!.totalWithTax / 100).toString());
  502. expect(createPaymentIntentPayload.currency).toBe('jpy');
  503. });
  504. });
  505. });