mollie-payment.e2e-spec.ts 31 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771
  1. import { OrderStatus } from '@mollie/api-client';
  2. import {
  3. ChannelService,
  4. EventBus,
  5. LanguageCode,
  6. Logger,
  7. mergeConfig,
  8. OrderPlacedEvent,
  9. OrderService,
  10. RequestContext
  11. } from '@vendure/core';
  12. import {
  13. SettlePaymentMutation,
  14. SettlePaymentMutationVariables,
  15. } from '@vendure/core/e2e/graphql/generated-e2e-admin-types';
  16. import { SETTLE_PAYMENT } from '@vendure/core/e2e/graphql/shared-definitions';
  17. import {
  18. createTestEnvironment,
  19. E2E_DEFAULT_CHANNEL_TOKEN,
  20. SimpleGraphQLClient,
  21. TestServer,
  22. } from '@vendure/testing';
  23. import nock from 'nock';
  24. import fetch from 'node-fetch';
  25. import path from 'path';
  26. import { afterAll, afterEach, beforeAll, describe, expect, it, vi } from 'vitest';
  27. import { initialData } from '../../../e2e-common/e2e-initial-data';
  28. import { TEST_SETUP_TIMEOUT_MS, testConfig } from '../../../e2e-common/test-config';
  29. import { UPDATE_PRODUCT_VARIANTS } from '../../core/e2e/graphql/shared-definitions';
  30. import { MolliePlugin } from '../src/mollie';
  31. import { molliePaymentHandler } from '../src/mollie/mollie.handler';
  32. import { CREATE_PAYMENT_METHOD, GET_CUSTOMER_LIST, GET_ORDER_PAYMENTS } from './graphql/admin-queries';
  33. import {
  34. CreatePaymentMethodMutation,
  35. CreatePaymentMethodMutationVariables,
  36. GetCustomerListQuery,
  37. GetCustomerListQueryVariables,
  38. } from './graphql/generated-admin-types';
  39. import {
  40. AddItemToOrderMutation,
  41. AddItemToOrderMutationVariables,
  42. AdjustOrderLineMutation,
  43. AdjustOrderLineMutationVariables,
  44. GetOrderByCodeQuery,
  45. GetOrderByCodeQueryVariables,
  46. TestOrderFragmentFragment,
  47. } from './graphql/generated-shop-types';
  48. import {
  49. ADD_ITEM_TO_ORDER,
  50. ADJUST_ORDER_LINE,
  51. APPLY_COUPON_CODE,
  52. GET_ORDER_BY_CODE
  53. } from './graphql/shop-queries';
  54. import {
  55. addManualPayment,
  56. CREATE_MOLLIE_PAYMENT_INTENT,
  57. createFixedDiscountCoupon,
  58. createFreeShippingCoupon,
  59. GET_MOLLIE_PAYMENT_METHODS,
  60. refundOrderLine,
  61. setShipping,
  62. testPaymentEligibilityChecker,
  63. } from './payment-helpers';
  64. const mockData = {
  65. host: 'https://my-vendure.io',
  66. redirectUrl: 'https://fallback-redirect/order',
  67. apiKey: 'myApiKey',
  68. methodCode: `mollie-payment-${E2E_DEFAULT_CHANNEL_TOKEN}`,
  69. methodCodeBroken: `mollie-payment-broken-${E2E_DEFAULT_CHANNEL_TOKEN}`,
  70. mollieOrderResponse: {
  71. id: 'ord_mockId',
  72. _links: {
  73. checkout: {
  74. href: 'https://www.mollie.com/payscreen/select-method/mock-payment',
  75. },
  76. },
  77. lines: [
  78. {
  79. resource: 'orderline',
  80. id: 'odl_3.c0qfy7',
  81. orderId: 'ord_1.6i4fed',
  82. name: 'Pinelab stickers',
  83. status: 'created',
  84. isCancelable: false,
  85. quantity: 10,
  86. createdAt: '2024-06-25T11:41:56+00:00',
  87. },
  88. {
  89. resource: 'orderline',
  90. id: 'odl_3.nj3d5u',
  91. orderId: 'ord_1.6i4fed',
  92. name: 'Express Shipping',
  93. isCancelable: false,
  94. quantity: 1,
  95. createdAt: '2024-06-25T11:41:57+00:00',
  96. },
  97. {
  98. resource: 'orderline',
  99. id: 'odl_3.nklsl4',
  100. orderId: 'ord_1.6i4fed',
  101. name: 'Negative test surcharge',
  102. isCancelable: false,
  103. quantity: 1,
  104. createdAt: '2024-06-25T11:41:57+00:00',
  105. },
  106. ],
  107. _embedded: {
  108. payments: [
  109. {
  110. id: 'tr_mockPayment',
  111. status: 'paid',
  112. resource: 'payment',
  113. },
  114. ],
  115. },
  116. resource: 'order',
  117. metadata: {
  118. languageCode: 'nl',
  119. },
  120. mode: 'test',
  121. method: 'test-method',
  122. profileId: '123',
  123. settlementAmount: 'test amount',
  124. customerId: '456',
  125. authorizedAt: new Date(),
  126. paidAt: new Date(),
  127. },
  128. molliePaymentMethodsResponse: {
  129. count: 1,
  130. _embedded: {
  131. methods: [
  132. {
  133. resource: 'method',
  134. id: 'ideal',
  135. description: 'iDEAL',
  136. minimumAmount: {
  137. value: '0.01',
  138. currency: 'EUR',
  139. },
  140. maximumAmount: {
  141. value: '50000.00',
  142. currency: 'EUR',
  143. },
  144. image: {
  145. size1x: 'https://www.mollie.com/external/icons/payment-methods/ideal.png',
  146. size2x: 'https://www.mollie.com/external/icons/payment-methods/ideal%402x.png',
  147. svg: 'https://www.mollie.com/external/icons/payment-methods/ideal.svg',
  148. },
  149. _links: {
  150. self: {
  151. href: 'https://api.mollie.com/v2/methods/ideal',
  152. type: 'application/hal+json',
  153. },
  154. },
  155. },
  156. ],
  157. },
  158. _links: {
  159. self: {
  160. href: 'https://api.mollie.com/v2/methods',
  161. type: 'application/hal+json',
  162. },
  163. documentation: {
  164. href: 'https://docs.mollie.com/reference/v2/methods-api/list-methods',
  165. type: 'text/html',
  166. },
  167. },
  168. },
  169. };
  170. let shopClient: SimpleGraphQLClient;
  171. let adminClient: SimpleGraphQLClient;
  172. let server: TestServer;
  173. let started = false;
  174. let customers: GetCustomerListQuery['customers']['items'];
  175. let order: TestOrderFragmentFragment;
  176. let serverPort: number;
  177. const SURCHARGE_AMOUNT = -20000;
  178. describe('Mollie payments', () => {
  179. beforeAll(async () => {
  180. const devConfig = mergeConfig(testConfig(), {
  181. plugins: [MolliePlugin.init({ vendureHost: mockData.host })],
  182. paymentOptions: {
  183. paymentMethodEligibilityCheckers: [testPaymentEligibilityChecker],
  184. },
  185. });
  186. const env = createTestEnvironment(devConfig);
  187. serverPort = devConfig.apiOptions.port;
  188. shopClient = env.shopClient;
  189. adminClient = env.adminClient;
  190. server = env.server;
  191. await server.init({
  192. initialData,
  193. productsCsvPath: path.join(__dirname, 'fixtures/e2e-products-minimal.csv'),
  194. customerCount: 2,
  195. });
  196. started = true;
  197. await adminClient.asSuperAdmin();
  198. ({
  199. customers: { items: customers },
  200. } = await adminClient.query<GetCustomerListQuery, GetCustomerListQueryVariables>(GET_CUSTOMER_LIST, {
  201. options: {
  202. take: 2,
  203. },
  204. }));
  205. }, TEST_SETUP_TIMEOUT_MS);
  206. afterAll(async () => {
  207. await server.destroy();
  208. });
  209. afterEach(() => {
  210. nock.cleanAll();
  211. });
  212. it('Should start successfully', () => {
  213. expect(started).toEqual(true);
  214. expect(customers).toHaveLength(2);
  215. });
  216. it('Should create a Mollie paymentMethod', async () => {
  217. const { createPaymentMethod } = await adminClient.query<
  218. CreatePaymentMethodMutation,
  219. CreatePaymentMethodMutationVariables
  220. >(CREATE_PAYMENT_METHOD, {
  221. input: {
  222. code: mockData.methodCode,
  223. enabled: true,
  224. checker: {
  225. code: testPaymentEligibilityChecker.code,
  226. arguments: [],
  227. },
  228. handler: {
  229. code: molliePaymentHandler.code,
  230. arguments: [
  231. { name: 'redirectUrl', value: mockData.redirectUrl },
  232. { name: 'apiKey', value: mockData.apiKey },
  233. { name: 'autoCapture', value: 'false' },
  234. ],
  235. },
  236. translations: [
  237. {
  238. languageCode: LanguageCode.en,
  239. name: 'Mollie payment test',
  240. description: 'This is a Mollie test payment method',
  241. },
  242. ],
  243. },
  244. });
  245. expect(createPaymentMethod.code).toBe(mockData.methodCode);
  246. });
  247. describe('Payment intent creation', () => {
  248. it('Should prepare an order', async () => {
  249. await shopClient.asUserWithCredentials(customers[0].emailAddress, 'test');
  250. const { addItemToOrder } = await shopClient.query<
  251. AddItemToOrderMutation,
  252. AddItemToOrderMutationVariables
  253. >(ADD_ITEM_TO_ORDER, {
  254. productVariantId: 'T_5',
  255. quantity: 10,
  256. });
  257. order = addItemToOrder as TestOrderFragmentFragment;
  258. // Add surcharge
  259. const ctx = new RequestContext({
  260. apiType: 'admin',
  261. isAuthorized: true,
  262. authorizedAsOwnerOnly: false,
  263. channel: await server.app.get(ChannelService).getDefaultChannel(),
  264. });
  265. await server.app.get(OrderService).addSurchargeToOrder(ctx, order.id.replace('T_', ''), {
  266. description: 'Negative test surcharge',
  267. listPrice: SURCHARGE_AMOUNT,
  268. });
  269. expect(order.code).toBeDefined();
  270. });
  271. it('Should fail to create payment intent without shippingmethod', async () => {
  272. await shopClient.asUserWithCredentials(customers[0].emailAddress, 'test');
  273. const { createMolliePaymentIntent: result } = await shopClient.query(
  274. CREATE_MOLLIE_PAYMENT_INTENT,
  275. {
  276. input: {
  277. paymentMethodCode: mockData.methodCode,
  278. },
  279. },
  280. );
  281. expect(result.errorCode).toBe('ORDER_PAYMENT_STATE_ERROR');
  282. });
  283. it('Should fail to create payment intent with invalid Mollie method', async () => {
  284. await shopClient.asUserWithCredentials(customers[0].emailAddress, 'test');
  285. await setShipping(shopClient);
  286. const { createMolliePaymentIntent: result } = await shopClient.query(
  287. CREATE_MOLLIE_PAYMENT_INTENT,
  288. {
  289. input: {
  290. paymentMethodCode: mockData.methodCode,
  291. molliePaymentMethodCode: 'invalid',
  292. },
  293. },
  294. );
  295. expect(result.errorCode).toBe('INELIGIBLE_PAYMENT_METHOD_ERROR');
  296. });
  297. it('Should fail to get payment url when items are out of stock', async () => {
  298. let { updateProductVariants } = await adminClient.query(UPDATE_PRODUCT_VARIANTS, {
  299. input: {
  300. id: 'T_5',
  301. trackInventory: 'TRUE',
  302. outOfStockThreshold: 0,
  303. stockOnHand: 1,
  304. },
  305. });
  306. expect(updateProductVariants[0].stockOnHand).toBe(1);
  307. const { createMolliePaymentIntent: result } = await shopClient.query(
  308. CREATE_MOLLIE_PAYMENT_INTENT,
  309. {
  310. input: {
  311. paymentMethodCode: mockData.methodCode,
  312. },
  313. },
  314. );
  315. expect(result.message).toContain('insufficient stock of Pinelab stickers');
  316. // Set stock back to not tracking
  317. ({ updateProductVariants } = await adminClient.query(UPDATE_PRODUCT_VARIANTS, {
  318. input: {
  319. id: 'T_5',
  320. trackInventory: 'FALSE',
  321. },
  322. }));
  323. expect(updateProductVariants[0].trackInventory).toBe('FALSE');
  324. });
  325. it('Should get payment url without Mollie method', async () => {
  326. let mollieRequest: any | undefined;
  327. nock('https://api.mollie.com/')
  328. .post('/v2/orders', body => {
  329. mollieRequest = body;
  330. return true;
  331. })
  332. .reply(200, mockData.mollieOrderResponse);
  333. const { createMolliePaymentIntent } = await shopClient.query(CREATE_MOLLIE_PAYMENT_INTENT, {
  334. input: {
  335. paymentMethodCode: mockData.methodCode,
  336. redirectUrl: 'given-storefront-redirect-url',
  337. },
  338. });
  339. expect(createMolliePaymentIntent).toEqual({
  340. url: 'https://www.mollie.com/payscreen/select-method/mock-payment',
  341. });
  342. expect(mollieRequest?.orderNumber).toEqual(order.code);
  343. expect(mollieRequest?.redirectUrl).toEqual('given-storefront-redirect-url');
  344. expect(mollieRequest?.webhookUrl).toEqual(
  345. `${mockData.host}/payments/mollie/${E2E_DEFAULT_CHANNEL_TOKEN}/1`,
  346. );
  347. expect(mollieRequest?.amount?.value).toBe('1009.88');
  348. expect(mollieRequest?.amount?.currency).toBe('USD');
  349. expect(mollieRequest.lines[0].vatAmount.value).toEqual('199.98');
  350. let totalLineAmount = 0;
  351. for (const line of mollieRequest.lines) {
  352. totalLineAmount += Number(line.totalAmount.value);
  353. }
  354. // Sum of lines should equal order total
  355. expect(mollieRequest.amount.value).toEqual(totalLineAmount.toFixed(2));
  356. });
  357. it('Should use fallback redirect appended with order code, when no redirect is given', async () => {
  358. let mollieRequest: any | undefined;
  359. nock('https://api.mollie.com/')
  360. .post('/v2/orders', body => {
  361. mollieRequest = body;
  362. return true;
  363. })
  364. .reply(200, mockData.mollieOrderResponse);
  365. await shopClient.query(CREATE_MOLLIE_PAYMENT_INTENT, {
  366. input: {
  367. paymentMethodCode: mockData.methodCode,
  368. },
  369. });
  370. expect(mollieRequest?.redirectUrl).toEqual(`${mockData.redirectUrl}/${order.code}`);
  371. });
  372. it('Should get payment url with Mollie method', async () => {
  373. nock('https://api.mollie.com/').post('/v2/orders').reply(200, mockData.mollieOrderResponse);
  374. await shopClient.asUserWithCredentials(customers[0].emailAddress, 'test');
  375. await setShipping(shopClient);
  376. const { createMolliePaymentIntent } = await shopClient.query(CREATE_MOLLIE_PAYMENT_INTENT, {
  377. input: {
  378. paymentMethodCode: mockData.methodCode,
  379. molliePaymentMethodCode: 'ideal',
  380. },
  381. });
  382. expect(createMolliePaymentIntent).toEqual({
  383. url: 'https://www.mollie.com/payscreen/select-method/mock-payment',
  384. });
  385. });
  386. it('Should not allow creating intent if payment method is not eligible', async () => {
  387. // Set quantity to 9, which is not allowe by our test eligibility checker
  388. await shopClient.query<AdjustOrderLineMutation, AdjustOrderLineMutationVariables>(
  389. ADJUST_ORDER_LINE,
  390. {
  391. orderLineId: order.lines[0].id,
  392. quantity: 9,
  393. },
  394. );
  395. let mollieRequest: any | undefined;
  396. nock('https://api.mollie.com/')
  397. .post('/v2/orders', body => {
  398. mollieRequest = body;
  399. return true;
  400. })
  401. .reply(200, mockData.mollieOrderResponse);
  402. const { createMolliePaymentIntent } = await shopClient.query(CREATE_MOLLIE_PAYMENT_INTENT, {
  403. input: {
  404. paymentMethodCode: mockData.methodCode,
  405. redirectUrl: 'given-storefront-redirect-url',
  406. },
  407. });
  408. expect(createMolliePaymentIntent.errorCode).toBe('INELIGIBLE_PAYMENT_METHOD_ERROR');
  409. expect(createMolliePaymentIntent.message).toContain('is not eligible for order');
  410. });
  411. it('Should get payment url with deducted amount if a payment is already made', async () => {
  412. // Change quantity back to 10
  413. await shopClient.query<AdjustOrderLineMutation, AdjustOrderLineMutationVariables>(
  414. ADJUST_ORDER_LINE,
  415. {
  416. orderLineId: order.lines[0].id,
  417. quantity: 10,
  418. },
  419. );
  420. let mollieRequest: any | undefined;
  421. nock('https://api.mollie.com/')
  422. .post('/v2/orders', body => {
  423. mollieRequest = body;
  424. return true;
  425. })
  426. .reply(200, mockData.mollieOrderResponse);
  427. await addManualPayment(server, 1, 10000);
  428. await shopClient.query(CREATE_MOLLIE_PAYMENT_INTENT, {
  429. input: {
  430. paymentMethodCode: mockData.methodCode,
  431. },
  432. });
  433. expect(mollieRequest.amount?.value).toBe('909.88'); // minus 100,00 from manual payment
  434. let totalLineAmount = 0;
  435. for (const line of mollieRequest?.lines) {
  436. totalLineAmount += Number(line.totalAmount.value);
  437. }
  438. // Sum of lines should equal order total
  439. expect(mollieRequest.amount.value).toEqual(totalLineAmount.toFixed(2));
  440. });
  441. it('Should create intent as admin', async () => {
  442. nock('https://api.mollie.com/').post('/v2/orders').reply(200, mockData.mollieOrderResponse);
  443. // Admin API passes order ID, and no payment method code
  444. const { createMolliePaymentIntent: intent } = await adminClient.query(
  445. CREATE_MOLLIE_PAYMENT_INTENT,
  446. {
  447. input: {
  448. orderId: '1',
  449. },
  450. },
  451. );
  452. expect(intent.url).toBe(mockData.mollieOrderResponse._links.checkout.href);
  453. });
  454. it('Should get available paymentMethods', async () => {
  455. nock('https://api.mollie.com/')
  456. .get('/v2/methods?resource=orders')
  457. .reply(200, mockData.molliePaymentMethodsResponse);
  458. await shopClient.asUserWithCredentials(customers[0].emailAddress, 'test');
  459. const { molliePaymentMethods } = await shopClient.query(GET_MOLLIE_PAYMENT_METHODS, {
  460. input: {
  461. paymentMethodCode: mockData.methodCode,
  462. },
  463. });
  464. const method = molliePaymentMethods[0];
  465. expect(method.code).toEqual('ideal');
  466. expect(method.minimumAmount).toBeDefined();
  467. expect(method.maximumAmount).toBeDefined();
  468. expect(method.image).toBeDefined();
  469. });
  470. it('Transitions to PaymentSettled for orders with a total of $0', async () => {
  471. await shopClient.asUserWithCredentials(customers[1].emailAddress, 'test');
  472. const { addItemToOrder } = await shopClient.query(ADD_ITEM_TO_ORDER, {
  473. productVariantId: 'T_1',
  474. quantity: 1,
  475. });
  476. await setShipping(shopClient);
  477. // Discount the order so it has a total of $0
  478. await createFixedDiscountCoupon(adminClient, 156880, 'DISCOUNT_ORDER');
  479. await createFreeShippingCoupon(adminClient, 'FREE_SHIPPING');
  480. await shopClient.query(APPLY_COUPON_CODE, { couponCode: 'DISCOUNT_ORDER' });
  481. await shopClient.query(APPLY_COUPON_CODE, { couponCode: 'FREE_SHIPPING' });
  482. // Create payment intent
  483. const { createMolliePaymentIntent: intent } = await shopClient.query(
  484. CREATE_MOLLIE_PAYMENT_INTENT,
  485. {
  486. input: {
  487. paymentMethodCode: mockData.methodCode,
  488. redirectUrl: 'https://my-storefront.io/order-confirmation',
  489. },
  490. },
  491. );
  492. const { orderByCode } = await shopClient.query(GET_ORDER_BY_CODE, { code: addItemToOrder.code });
  493. expect(intent.url).toBe('https://my-storefront.io/order-confirmation');
  494. expect(orderByCode.totalWithTax).toBe(0);
  495. expect(orderByCode.state).toBe('PaymentSettled');
  496. });
  497. });
  498. describe('Handle standard payment methods', () => {
  499. it('Should transition to ArrangingPayment when partially paid', async () => {
  500. nock('https://api.mollie.com/')
  501. .get('/v2/orders/ord_mockId')
  502. .reply(200, {
  503. ...mockData.mollieOrderResponse,
  504. // Add a payment of 20.00
  505. amount: { value: '20.00', currency: 'EUR' },
  506. orderNumber: order.code,
  507. status: OrderStatus.paid,
  508. });
  509. await fetch(`http://localhost:${serverPort}/payments/mollie/${E2E_DEFAULT_CHANNEL_TOKEN}/1`, {
  510. method: 'post',
  511. body: JSON.stringify({ id: mockData.mollieOrderResponse.id }),
  512. headers: { 'Content-Type': 'application/json' },
  513. });
  514. const { order: adminOrder } = await adminClient.query(GET_ORDER_PAYMENTS, { id: order?.id });
  515. expect(adminOrder.state).toBe('ArrangingPayment');
  516. });
  517. let orderPlacedEvent: OrderPlacedEvent | undefined;
  518. it('Should place order after paying outstanding amount', async () => {
  519. server.app
  520. .get(EventBus)
  521. .ofType(OrderPlacedEvent)
  522. .subscribe(event => {
  523. orderPlacedEvent = event;
  524. });
  525. nock('https://api.mollie.com/')
  526. .get('/v2/orders/ord_mockId')
  527. .reply(200, {
  528. ...mockData.mollieOrderResponse,
  529. // Add a payment of 1089.90
  530. amount: { value: '1089.90', currency: 'EUR' }, // 1109.90 minus the previously paid 20.00
  531. orderNumber: order.code,
  532. status: OrderStatus.paid,
  533. });
  534. await fetch(`http://localhost:${serverPort}/payments/mollie/${E2E_DEFAULT_CHANNEL_TOKEN}/1`, {
  535. method: 'post',
  536. body: JSON.stringify({ id: mockData.mollieOrderResponse.id }),
  537. headers: { 'Content-Type': 'application/json' },
  538. });
  539. await shopClient.asUserWithCredentials(customers[0].emailAddress, 'test');
  540. const { orderByCode } = await shopClient.query<GetOrderByCodeQuery, GetOrderByCodeQueryVariables>(
  541. GET_ORDER_BY_CODE,
  542. {
  543. code: order.code,
  544. },
  545. );
  546. // eslint-disable-next-line @typescript-eslint/no-non-null-assertion
  547. order = orderByCode!;
  548. expect(order.state).toBe('PaymentSettled');
  549. });
  550. it('Should log error when order is paid again with a different mollie order', async () => {
  551. const errorLogSpy = vi.spyOn(Logger, 'error');
  552. nock('https://api.mollie.com/')
  553. .get('/v2/orders/ord_newMockId')
  554. .reply(200, {
  555. ...mockData.mollieOrderResponse,
  556. id: 'ord_newMockId',
  557. amount: { value: '100', currency: 'EUR' }, // Try to pay another 100
  558. orderNumber: order.code,
  559. status: OrderStatus.paid,
  560. });
  561. await fetch(`http://localhost:${serverPort}/payments/mollie/${E2E_DEFAULT_CHANNEL_TOKEN}/1`, {
  562. method: 'post',
  563. body: JSON.stringify({ id: 'ord_newMockId' }),
  564. headers: { 'Content-Type': 'application/json' },
  565. });
  566. const logMessage = errorLogSpy.mock.calls?.[0]?.[0];
  567. expect(logMessage).toBe(
  568. `Order '${order.code}' is already paid. Mollie order 'ord_newMockId' should be refunded.`,
  569. );
  570. });
  571. it('Should have preserved original languageCode ', () => {
  572. // We've set the languageCode to 'nl' in the mock response's metadata
  573. expect(orderPlacedEvent?.ctx.languageCode).toBe('nl');
  574. });
  575. it('Resulting events should have a ctx.req ', () => {
  576. // We've set the languageCode to 'nl' in the mock response's metadata
  577. expect(orderPlacedEvent?.ctx?.req).toBeDefined();
  578. });
  579. it('Should have Mollie metadata on payment', async () => {
  580. const {
  581. order: { payments },
  582. } = await adminClient.query(GET_ORDER_PAYMENTS, { id: order.id });
  583. const metadata = payments[1].metadata;
  584. expect(metadata.mode).toBe(mockData.mollieOrderResponse.mode);
  585. expect(metadata.method).toBe(mockData.mollieOrderResponse.method);
  586. expect(metadata.profileId).toBe(mockData.mollieOrderResponse.profileId);
  587. expect(metadata.authorizedAt).toEqual(mockData.mollieOrderResponse.authorizedAt.toISOString());
  588. expect(metadata.paidAt).toEqual(mockData.mollieOrderResponse.paidAt.toISOString());
  589. });
  590. it('Should fail to refund', async () => {
  591. nock('https://api.mollie.com/')
  592. .get('/v2/orders/ord_mockId?embed=payments')
  593. .reply(200, mockData.mollieOrderResponse);
  594. nock('https://api.mollie.com/')
  595. .post('/v2/payments/tr_mockPayment/refunds')
  596. .reply(200, { status: 'failed', resource: 'payment' });
  597. const refund = await refundOrderLine(
  598. adminClient,
  599. order.lines[0].id,
  600. 1,
  601. // eslint-disable-next-line @typescript-eslint/no-non-null-assertion
  602. order!.payments![1].id,
  603. SURCHARGE_AMOUNT,
  604. );
  605. expect(refund.state).toBe('Failed');
  606. });
  607. it('Should successfully refund the Mollie payment', async () => {
  608. let mollieRequest: any;
  609. nock('https://api.mollie.com/')
  610. .get('/v2/orders/ord_mockId?embed=payments')
  611. .reply(200, mockData.mollieOrderResponse);
  612. nock('https://api.mollie.com/')
  613. .post('/v2/payments/tr_mockPayment/refunds', body => {
  614. mollieRequest = body;
  615. return true;
  616. })
  617. .reply(200, { status: 'pending', resource: 'payment' });
  618. const refund = await refundOrderLine(
  619. adminClient,
  620. order.lines[0].id,
  621. 10,
  622. // eslint-disable-next-line @typescript-eslint/no-non-null-assertion
  623. order.payments!.find(p => p.amount === 108990)!.id,
  624. SURCHARGE_AMOUNT,
  625. );
  626. expect(mollieRequest?.amount.value).toBe('999.90'); // Only refund mollie amount, not the gift card
  627. expect(refund.total).toBe(99990);
  628. expect(refund.state).toBe('Settled');
  629. });
  630. });
  631. describe('Handle pay-later methods', () => {
  632. // TODO: Add testcases that mock incoming webhook to: 1. Authorize payment and 2. AutoCapture payments
  633. it('Should prepare a new order', async () => {
  634. await shopClient.asUserWithCredentials(customers[0].emailAddress, 'test');
  635. const { addItemToOrder } = await shopClient.query<
  636. AddItemToOrderMutation,
  637. AddItemToOrderMutationVariables
  638. >(ADD_ITEM_TO_ORDER, {
  639. productVariantId: 'T_1',
  640. quantity: 2,
  641. });
  642. order = addItemToOrder as TestOrderFragmentFragment;
  643. await setShipping(shopClient);
  644. expect(order.code).toBeDefined();
  645. });
  646. it('Should authorize payment for pay-later payment methods', async () => {
  647. nock('https://api.mollie.com/')
  648. .get('/v2/orders/ord_mockId')
  649. .reply(200, {
  650. ...mockData.mollieOrderResponse,
  651. amount: { value: '3127.60', currency: 'EUR' },
  652. orderNumber: order.code,
  653. status: OrderStatus.authorized,
  654. });
  655. await fetch(`http://localhost:${serverPort}/payments/mollie/${E2E_DEFAULT_CHANNEL_TOKEN}/1`, {
  656. method: 'post',
  657. body: JSON.stringify({ id: mockData.mollieOrderResponse.id }),
  658. headers: { 'Content-Type': 'application/json' },
  659. });
  660. const { orderByCode } = await shopClient.query<GetOrderByCodeQuery, GetOrderByCodeQueryVariables>(
  661. GET_ORDER_BY_CODE,
  662. {
  663. code: order.code,
  664. },
  665. );
  666. // eslint-disable-next-line @typescript-eslint/no-non-null-assertion
  667. order = orderByCode!;
  668. expect(order.state).toBe('PaymentAuthorized');
  669. });
  670. it('Should settle payment via settlePayment mutation', async () => {
  671. // Mock the getOrder Mollie call
  672. nock('https://api.mollie.com/')
  673. .get('/v2/orders/ord_mockId')
  674. .reply(200, {
  675. ...mockData.mollieOrderResponse,
  676. orderNumber: order.code,
  677. status: OrderStatus.authorized,
  678. });
  679. // Mock the createShipment call
  680. let createShipmentBody;
  681. nock('https://api.mollie.com/')
  682. .post('/v2/orders/ord_mockId/shipments', body => {
  683. createShipmentBody = body;
  684. return true;
  685. })
  686. .reply(200, { resource: 'shipment', lines: [] });
  687. const { settlePayment } = await adminClient.query<
  688. SettlePaymentMutation,
  689. SettlePaymentMutationVariables
  690. >(SETTLE_PAYMENT, {
  691. // eslint-disable-next-line @typescript-eslint/no-non-null-assertion
  692. id: order.payments![0].id,
  693. });
  694. const { orderByCode } = await shopClient.query<GetOrderByCodeQuery, GetOrderByCodeQueryVariables>(
  695. GET_ORDER_BY_CODE,
  696. {
  697. code: order.code,
  698. },
  699. );
  700. // eslint-disable-next-line @typescript-eslint/no-non-null-assertion
  701. order = orderByCode!;
  702. expect(createShipmentBody).toBeDefined();
  703. expect(order.state).toBe('PaymentSettled');
  704. });
  705. it('Should fail to add payment method without redirect url', async () => {
  706. let error = '';
  707. try {
  708. const { createPaymentMethod } = await adminClient.query<
  709. CreatePaymentMethodMutation,
  710. CreatePaymentMethodMutationVariables
  711. >(CREATE_PAYMENT_METHOD, {
  712. input: {
  713. code: mockData.methodCodeBroken,
  714. enabled: true,
  715. handler: {
  716. code: molliePaymentHandler.code,
  717. arguments: [
  718. { name: 'apiKey', value: mockData.apiKey },
  719. { name: 'autoCapture', value: 'false' },
  720. ],
  721. },
  722. translations: [
  723. {
  724. languageCode: LanguageCode.en,
  725. name: 'Mollie payment test',
  726. description: 'This is a Mollie test payment method',
  727. },
  728. ],
  729. },
  730. });
  731. } catch (e: any) {
  732. error = e.message;
  733. }
  734. expect(error).toBe('The argument "redirectUrl" is required');
  735. });
  736. });
  737. });