stripe-payment.e2e-spec.ts 23 KB

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