mollie-payment.e2e-spec.ts 22 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530
  1. import { OrderStatus } from '@mollie/api-client';
  2. import {
  3. ChannelService,
  4. DefaultLogger,
  5. LogLevel,
  6. mergeConfig,
  7. OrderService,
  8. RequestContext,
  9. } from '@vendure/core';
  10. import {
  11. SettlePaymentMutation,
  12. SettlePaymentMutationVariables,
  13. } from '@vendure/core/e2e/graphql/generated-e2e-admin-types';
  14. import { SETTLE_PAYMENT } from '@vendure/core/e2e/graphql/shared-definitions';
  15. import {
  16. createTestEnvironment,
  17. E2E_DEFAULT_CHANNEL_TOKEN,
  18. SimpleGraphQLClient,
  19. TestServer,
  20. } from '@vendure/testing';
  21. import nock from 'nock';
  22. import fetch from 'node-fetch';
  23. import path from 'path';
  24. import { initialData } from '../../../e2e-common/e2e-initial-data';
  25. import { testConfig, TEST_SETUP_TIMEOUT_MS } from '../../../e2e-common/test-config';
  26. import { UPDATE_PRODUCT_VARIANTS } from '../../core/e2e/graphql/shared-definitions';
  27. import { MolliePlugin } from '../src/mollie';
  28. import { molliePaymentHandler } from '../src/mollie/mollie.handler';
  29. import { CREATE_PAYMENT_METHOD, GET_CUSTOMER_LIST, GET_ORDER_PAYMENTS } from './graphql/admin-queries';
  30. import { CreatePaymentMethod, GetCustomerList, GetCustomerListQuery } from './graphql/generated-admin-types';
  31. import { AddItemToOrder, GetOrderByCode, TestOrderFragmentFragment } from './graphql/generated-shop-types';
  32. import { ADD_ITEM_TO_ORDER, GET_ORDER_BY_CODE } from './graphql/shop-queries';
  33. import {
  34. addManualPayment,
  35. CREATE_MOLLIE_PAYMENT_INTENT,
  36. GET_MOLLIE_PAYMENT_METHODS,
  37. refundOrderLine,
  38. setShipping,
  39. } from './payment-helpers';
  40. describe('Mollie payments', () => {
  41. const mockData = {
  42. host: 'https://my-vendure.io',
  43. redirectUrl: 'https://my-storefront/order',
  44. apiKey: 'myApiKey',
  45. methodCode: `mollie-payment-${E2E_DEFAULT_CHANNEL_TOKEN}`,
  46. mollieOrderResponse: {
  47. id: 'ord_mockId',
  48. _links: {
  49. checkout: {
  50. href: 'https://www.mollie.com/payscreen/select-method/mock-payment',
  51. },
  52. },
  53. lines: [],
  54. _embedded: {
  55. payments: [
  56. {
  57. id: 'tr_mockPayment',
  58. status: 'paid',
  59. resource: 'payment',
  60. },
  61. ],
  62. },
  63. resource: 'order',
  64. mode: 'test',
  65. method: 'test-method',
  66. profileId: '123',
  67. settlementAmount: 'test amount',
  68. customerId: '456',
  69. authorizedAt: new Date(),
  70. paidAt: new Date(),
  71. },
  72. molliePaymentMethodsResponse: {
  73. count: 1,
  74. _embedded: {
  75. methods: [
  76. {
  77. resource: 'method',
  78. id: 'ideal',
  79. description: 'iDEAL',
  80. minimumAmount: {
  81. value: '0.01',
  82. currency: 'EUR',
  83. },
  84. maximumAmount: {
  85. value: '50000.00',
  86. currency: 'EUR',
  87. },
  88. image: {
  89. size1x: 'https://www.mollie.com/external/icons/payment-methods/ideal.png',
  90. size2x: 'https://www.mollie.com/external/icons/payment-methods/ideal%402x.png',
  91. svg: 'https://www.mollie.com/external/icons/payment-methods/ideal.svg',
  92. },
  93. _links: {
  94. self: {
  95. href: 'https://api.mollie.com/v2/methods/ideal',
  96. type: 'application/hal+json',
  97. },
  98. },
  99. },
  100. ],
  101. },
  102. _links: {
  103. self: {
  104. href: 'https://api.mollie.com/v2/methods',
  105. type: 'application/hal+json',
  106. },
  107. documentation: {
  108. href: 'https://docs.mollie.com/reference/v2/methods-api/list-methods',
  109. type: 'text/html',
  110. },
  111. },
  112. },
  113. };
  114. let shopClient: SimpleGraphQLClient;
  115. let adminClient: SimpleGraphQLClient;
  116. let server: TestServer;
  117. let started = false;
  118. let customers: GetCustomerListQuery['customers']['items'];
  119. let order: TestOrderFragmentFragment;
  120. let serverPort: number;
  121. const SURCHARGE_AMOUNT = -20000;
  122. beforeAll(async () => {
  123. const devConfig = mergeConfig(testConfig(), {
  124. plugins: [MolliePlugin.init({ vendureHost: mockData.host })],
  125. });
  126. const env = createTestEnvironment(devConfig);
  127. serverPort = devConfig.apiOptions.port;
  128. shopClient = env.shopClient;
  129. adminClient = env.adminClient;
  130. server = env.server;
  131. await server.init({
  132. initialData,
  133. productsCsvPath: path.join(__dirname, 'fixtures/e2e-products-minimal.csv'),
  134. customerCount: 2,
  135. });
  136. started = true;
  137. await adminClient.asSuperAdmin();
  138. ({
  139. customers: { items: customers },
  140. } = await adminClient.query<GetCustomerList.Query, GetCustomerList.Variables>(GET_CUSTOMER_LIST, {
  141. options: {
  142. take: 2,
  143. },
  144. }));
  145. }, TEST_SETUP_TIMEOUT_MS);
  146. afterAll(async () => {
  147. await server.destroy();
  148. });
  149. it('Should start successfully', async () => {
  150. expect(started).toEqual(true);
  151. expect(customers).toHaveLength(2);
  152. });
  153. describe('Payment intent creation', () => {
  154. it('Should prepare an order', async () => {
  155. await shopClient.asUserWithCredentials(customers[0].emailAddress, 'test');
  156. const { addItemToOrder } = await shopClient.query<
  157. AddItemToOrder.Mutation,
  158. AddItemToOrder.Variables
  159. >(ADD_ITEM_TO_ORDER, {
  160. productVariantId: 'T_5',
  161. quantity: 10,
  162. });
  163. order = addItemToOrder as TestOrderFragmentFragment;
  164. // Add surcharge
  165. const ctx = new RequestContext({
  166. apiType: 'admin',
  167. isAuthorized: true,
  168. authorizedAsOwnerOnly: false,
  169. channel: await server.app.get(ChannelService).getDefaultChannel(),
  170. });
  171. await server.app.get(OrderService).addSurchargeToOrder(ctx, 1, {
  172. description: 'Negative test surcharge',
  173. listPrice: SURCHARGE_AMOUNT,
  174. });
  175. expect(order.code).toBeDefined();
  176. });
  177. it('Should add a Mollie paymentMethod', async () => {
  178. const { createPaymentMethod } = await adminClient.query<
  179. CreatePaymentMethod.Mutation,
  180. CreatePaymentMethod.Variables
  181. >(CREATE_PAYMENT_METHOD, {
  182. input: {
  183. code: mockData.methodCode,
  184. name: 'Mollie payment test',
  185. description: 'This is a Mollie test payment method',
  186. enabled: true,
  187. handler: {
  188. code: molliePaymentHandler.code,
  189. arguments: [
  190. { name: 'redirectUrl', value: mockData.redirectUrl },
  191. { name: 'apiKey', value: mockData.apiKey },
  192. { name: 'autoCapture', value: 'false' },
  193. ],
  194. },
  195. },
  196. });
  197. expect(createPaymentMethod.code).toBe(mockData.methodCode);
  198. });
  199. it('Should fail to create payment intent without shippingmethod', async () => {
  200. await shopClient.asUserWithCredentials(customers[0].emailAddress, 'test');
  201. const { createMolliePaymentIntent: result } = await shopClient.query(
  202. CREATE_MOLLIE_PAYMENT_INTENT,
  203. {
  204. input: {
  205. paymentMethodCode: mockData.methodCode,
  206. },
  207. },
  208. );
  209. expect(result.errorCode).toBe('ORDER_PAYMENT_STATE_ERROR');
  210. });
  211. it('Should fail to create payment intent with invalid Mollie method', async () => {
  212. await shopClient.asUserWithCredentials(customers[0].emailAddress, 'test');
  213. await setShipping(shopClient);
  214. const { createMolliePaymentIntent: result } = await shopClient.query(
  215. CREATE_MOLLIE_PAYMENT_INTENT,
  216. {
  217. input: {
  218. paymentMethodCode: mockData.methodCode,
  219. molliePaymentMethodCode: 'invalid',
  220. },
  221. },
  222. );
  223. expect(result.errorCode).toBe('INELIGIBLE_PAYMENT_METHOD_ERROR');
  224. });
  225. it('Should fail to get payment url when items are out of stock', async () => {
  226. let { updateProductVariants } = await adminClient.query(UPDATE_PRODUCT_VARIANTS, {
  227. input: {
  228. id: 'T_5',
  229. trackInventory: 'TRUE',
  230. outOfStockThreshold: 0,
  231. stockOnHand: 1,
  232. },
  233. });
  234. expect(updateProductVariants[0].stockOnHand).toBe(1);
  235. const { createMolliePaymentIntent: result } = await shopClient.query(
  236. CREATE_MOLLIE_PAYMENT_INTENT,
  237. {
  238. input: {
  239. paymentMethodCode: mockData.methodCode,
  240. },
  241. },
  242. );
  243. expect(result.message).toContain('The following variants are out of stock');
  244. // Set stock back to not tracking
  245. ({ updateProductVariants } = await adminClient.query(UPDATE_PRODUCT_VARIANTS, {
  246. input: {
  247. id: 'T_5',
  248. trackInventory: 'FALSE',
  249. },
  250. }));
  251. expect(updateProductVariants[0].trackInventory).toBe('FALSE');
  252. });
  253. it('Should get payment url without Mollie method', async () => {
  254. let mollieRequest: any | undefined;
  255. nock('https://api.mollie.com/')
  256. .post('/v2/orders', body => {
  257. mollieRequest = body;
  258. return true;
  259. })
  260. .reply(200, mockData.mollieOrderResponse);
  261. const { createMolliePaymentIntent } = await shopClient.query(CREATE_MOLLIE_PAYMENT_INTENT, {
  262. input: {
  263. paymentMethodCode: mockData.methodCode,
  264. },
  265. });
  266. expect(createMolliePaymentIntent).toEqual({
  267. url: 'https://www.mollie.com/payscreen/select-method/mock-payment',
  268. });
  269. expect(mollieRequest?.orderNumber).toEqual(order.code);
  270. expect(mollieRequest?.redirectUrl).toEqual(`${mockData.redirectUrl}/${order.code}`);
  271. expect(mollieRequest?.webhookUrl).toEqual(
  272. `${mockData.host}/payments/mollie/${E2E_DEFAULT_CHANNEL_TOKEN}/1`,
  273. );
  274. expect(mollieRequest?.amount?.value).toBe('1009.90');
  275. expect(mollieRequest?.amount?.currency).toBe('USD');
  276. expect(mollieRequest.lines[0].vatAmount.value).toEqual('199.98');
  277. let totalLineAmount = 0;
  278. for (const line of mollieRequest.lines) {
  279. totalLineAmount += Number(line.totalAmount.value);
  280. }
  281. // Sum of lines should equal order total
  282. expect(mollieRequest.amount.value).toEqual(totalLineAmount.toFixed(2));
  283. });
  284. it('Should get payment url with Mollie method', async () => {
  285. nock('https://api.mollie.com/').post('/v2/orders').reply(200, mockData.mollieOrderResponse);
  286. await shopClient.asUserWithCredentials(customers[0].emailAddress, 'test');
  287. await setShipping(shopClient);
  288. const { createMolliePaymentIntent } = await shopClient.query(CREATE_MOLLIE_PAYMENT_INTENT, {
  289. input: {
  290. paymentMethodCode: mockData.methodCode,
  291. molliePaymentMethodCode: 'ideal',
  292. },
  293. });
  294. expect(createMolliePaymentIntent).toEqual({
  295. url: 'https://www.mollie.com/payscreen/select-method/mock-payment',
  296. });
  297. });
  298. it('Should get payment url with deducted amount if a payment is already made', async () => {
  299. let mollieRequest: any | undefined;
  300. nock('https://api.mollie.com/')
  301. .post('/v2/orders', body => {
  302. mollieRequest = body;
  303. return true;
  304. })
  305. .reply(200, mockData.mollieOrderResponse);
  306. await addManualPayment(server, 1, 10000);
  307. await shopClient.query(CREATE_MOLLIE_PAYMENT_INTENT, {
  308. input: {
  309. paymentMethodCode: mockData.methodCode,
  310. },
  311. });
  312. expect(mollieRequest.amount?.value).toBe('909.90'); // minus 100,00 from manual payment
  313. let totalLineAmount = 0;
  314. for (const line of mollieRequest?.lines) {
  315. totalLineAmount += Number(line.totalAmount.value);
  316. }
  317. // Sum of lines should equal order total
  318. expect(mollieRequest.amount.value).toEqual(totalLineAmount.toFixed(2));
  319. });
  320. it('Should get available paymentMethods', async () => {
  321. nock('https://api.mollie.com/')
  322. .get('/v2/methods')
  323. .reply(200, mockData.molliePaymentMethodsResponse);
  324. await shopClient.asUserWithCredentials(customers[0].emailAddress, 'test');
  325. const { molliePaymentMethods } = await shopClient.query(GET_MOLLIE_PAYMENT_METHODS, {
  326. input: {
  327. paymentMethodCode: mockData.methodCode,
  328. },
  329. });
  330. const method = molliePaymentMethods[0];
  331. expect(method.code).toEqual('ideal');
  332. expect(method.minimumAmount).toBeDefined();
  333. expect(method.maximumAmount).toBeDefined();
  334. expect(method.image).toBeDefined();
  335. });
  336. });
  337. describe('Handle standard payment methods', () => {
  338. it('Should transition to ArrangingPayment when partially paid', async () => {
  339. nock('https://api.mollie.com/')
  340. .get('/v2/orders/ord_mockId')
  341. .reply(200, {
  342. ...mockData.mollieOrderResponse,
  343. // Add a payment of 20.00
  344. amount: { value: '20.00', currency: 'EUR' },
  345. orderNumber: order.code,
  346. status: OrderStatus.paid,
  347. });
  348. await fetch(`http://localhost:${serverPort}/payments/mollie/${E2E_DEFAULT_CHANNEL_TOKEN}/1`, {
  349. method: 'post',
  350. body: JSON.stringify({ id: mockData.mollieOrderResponse.id }),
  351. headers: { 'Content-Type': 'application/json' },
  352. });
  353. // tslint:disable-next-line:no-non-null-assertion
  354. const { order: adminOrder } = await adminClient.query(GET_ORDER_PAYMENTS, { id: order!.id });
  355. expect(adminOrder.state).toBe('ArrangingPayment');
  356. });
  357. it('Should place order after paying outstanding amount', async () => {
  358. nock('https://api.mollie.com/')
  359. .get('/v2/orders/ord_mockId')
  360. .reply(200, {
  361. ...mockData.mollieOrderResponse,
  362. // Add a payment of 1089.90
  363. amount: { value: '1089.90', currency: 'EUR' }, // 1109.90 minus the previously paid 20.00
  364. orderNumber: order.code,
  365. status: OrderStatus.paid,
  366. });
  367. await fetch(`http://localhost:${serverPort}/payments/mollie/${E2E_DEFAULT_CHANNEL_TOKEN}/1`, {
  368. method: 'post',
  369. body: JSON.stringify({ id: mockData.mollieOrderResponse.id }),
  370. headers: { 'Content-Type': 'application/json' },
  371. });
  372. const { orderByCode } = await shopClient.query<GetOrderByCode.Query, GetOrderByCode.Variables>(
  373. GET_ORDER_BY_CODE,
  374. {
  375. code: order.code,
  376. },
  377. );
  378. // tslint:disable-next-line:no-non-null-assertion
  379. order = orderByCode!;
  380. expect(order.state).toBe('PaymentSettled');
  381. });
  382. it('Should have Mollie metadata on payment', async () => {
  383. const {
  384. order: { payments },
  385. } = await adminClient.query(GET_ORDER_PAYMENTS, { id: order.id });
  386. const metadata = payments[1].metadata;
  387. expect(metadata.mode).toBe(mockData.mollieOrderResponse.mode);
  388. expect(metadata.method).toBe(mockData.mollieOrderResponse.method);
  389. expect(metadata.profileId).toBe(mockData.mollieOrderResponse.profileId);
  390. expect(metadata.authorizedAt).toEqual(mockData.mollieOrderResponse.authorizedAt.toISOString());
  391. expect(metadata.paidAt).toEqual(mockData.mollieOrderResponse.paidAt.toISOString());
  392. });
  393. it('Should fail to refund', async () => {
  394. nock('https://api.mollie.com/')
  395. .get('/v2/orders/ord_mockId?embed=payments')
  396. .reply(200, mockData.mollieOrderResponse);
  397. nock('https://api.mollie.com/')
  398. .post('/v2/payments/tr_mockPayment/refunds')
  399. .reply(200, { status: 'failed', resource: 'payment' });
  400. const refund = await refundOrderLine(
  401. adminClient,
  402. order.lines[0].id,
  403. 1,
  404. // tslint:disable-next-line:no-non-null-assertion
  405. order!.payments[1].id,
  406. SURCHARGE_AMOUNT,
  407. );
  408. expect(refund.state).toBe('Failed');
  409. });
  410. it('Should successfully refund the Mollie payment', async () => {
  411. let mollieRequest;
  412. nock('https://api.mollie.com/')
  413. .get('/v2/orders/ord_mockId?embed=payments')
  414. .reply(200, mockData.mollieOrderResponse);
  415. nock('https://api.mollie.com/')
  416. .post('/v2/payments/tr_mockPayment/refunds', body => {
  417. mollieRequest = body;
  418. return true;
  419. })
  420. .reply(200, { status: 'pending', resource: 'payment' });
  421. const refund = await refundOrderLine(
  422. adminClient,
  423. order.lines[0].id,
  424. 10,
  425. // tslint:disable-next-line:no-non-null-assertion
  426. order.payments!.find(p => p.amount === 108990)!.id,
  427. SURCHARGE_AMOUNT,
  428. );
  429. expect(mollieRequest?.amount.value).toBe('999.90'); // Only refund mollie amount, not the gift card
  430. expect(refund.total).toBe(99990);
  431. expect(refund.state).toBe('Settled');
  432. });
  433. });
  434. describe('Handle pay-later methods', () => {
  435. it('Should prepare a new order', async () => {
  436. await shopClient.asUserWithCredentials(customers[0].emailAddress, 'test');
  437. const { addItemToOrder } = await shopClient.query<
  438. AddItemToOrder.Mutation,
  439. AddItemToOrder.Variables
  440. >(ADD_ITEM_TO_ORDER, {
  441. productVariantId: 'T_1',
  442. quantity: 2,
  443. });
  444. order = addItemToOrder as TestOrderFragmentFragment;
  445. await setShipping(shopClient);
  446. expect(order.code).toBeDefined();
  447. });
  448. it('Should authorize payment for pay-later payment methods', async () => {
  449. nock('https://api.mollie.com/')
  450. .get('/v2/orders/ord_mockId')
  451. .reply(200, {
  452. ...mockData.mollieOrderResponse,
  453. amount: { value: '3127.60', currency: 'EUR' },
  454. orderNumber: order.code,
  455. status: OrderStatus.authorized,
  456. });
  457. await fetch(`http://localhost:${serverPort}/payments/mollie/${E2E_DEFAULT_CHANNEL_TOKEN}/1`, {
  458. method: 'post',
  459. body: JSON.stringify({ id: mockData.mollieOrderResponse.id }),
  460. headers: { 'Content-Type': 'application/json' },
  461. });
  462. const { orderByCode } = await shopClient.query<GetOrderByCode.Query, GetOrderByCode.Variables>(
  463. GET_ORDER_BY_CODE,
  464. {
  465. code: order.code,
  466. },
  467. );
  468. // tslint:disable-next-line:no-non-null-assertion
  469. order = orderByCode!;
  470. expect(order.state).toBe('PaymentAuthorized');
  471. });
  472. it('Should settle payment via settlePayment mutation', async () => {
  473. // Mock the getOrder Mollie call
  474. nock('https://api.mollie.com/')
  475. .get('/v2/orders/ord_mockId')
  476. .reply(200, {
  477. ...mockData.mollieOrderResponse,
  478. orderNumber: order.code,
  479. status: OrderStatus.authorized,
  480. });
  481. // Mock the createShipment call
  482. let createShipmentBody;
  483. nock('https://api.mollie.com/')
  484. .post('/v2/orders/ord_mockId/shipments', body => {
  485. createShipmentBody = body;
  486. return true;
  487. })
  488. .reply(200, { resource: 'shipment', lines: [] });
  489. const { settlePayment } = await adminClient.query<
  490. SettlePaymentMutation,
  491. SettlePaymentMutationVariables
  492. >(SETTLE_PAYMENT, {
  493. // tslint:disable-next-line:no-non-null-assertion
  494. id: order.payments![0].id,
  495. });
  496. const { orderByCode } = await shopClient.query<GetOrderByCode.Query, GetOrderByCode.Variables>(
  497. GET_ORDER_BY_CODE,
  498. {
  499. code: order.code,
  500. },
  501. );
  502. // tslint:disable-next-line:no-non-null-assertion
  503. order = orderByCode!;
  504. expect(createShipmentBody).toBeDefined();
  505. expect(order.state).toBe('PaymentSettled');
  506. });
  507. });
  508. });