mollie-payment.e2e-spec.ts 32 KB

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