diff --git a/packages/payments-plugin/e2e/stripe-checkout-test.plugin.ts b/packages/payments-plugin/e2e/fixtures/stripe-checkout-test.plugin.ts similarity index 100% rename from packages/payments-plugin/e2e/stripe-checkout-test.plugin.ts rename to packages/payments-plugin/e2e/fixtures/stripe-checkout-test.plugin.ts diff --git a/packages/payments-plugin/e2e/mollie-dev-server.ts b/packages/payments-plugin/e2e/mollie-dev-server.ts index 442685bd96..03262c318e 100644 --- a/packages/payments-plugin/e2e/mollie-dev-server.ts +++ b/packages/payments-plugin/e2e/mollie-dev-server.ts @@ -26,7 +26,6 @@ import { /** * This should only be used to locally test the Mollie payment plugin * Make sure you have `MOLLIE_APIKEY=test_xxxx` in your .env file - * Make sure you have `MOLLIE_APIKEY=test_xxxx` in your .env file */ /* eslint-disable @typescript-eslint/no-floating-promises */ async function runMollieDevServer() { @@ -109,13 +108,10 @@ async function runMollieDevServer() { // eslint-disable-next-line no-console console.log('Payment intent result', result); - // Change order amount and create new intent - await createFixedDiscountCoupon(adminClient, 20000, 'DISCOUNT_ORDER'); - await shopClient.query(APPLY_COUPON_CODE, { couponCode: 'DISCOUNT_ORDER' }); - await new Promise(resolve => setTimeout(resolve, 3000)); + // Create another Payment Intent to test duplicate paymnets const result2 = await shopClient.query(CREATE_MOLLIE_PAYMENT_INTENT, { input: {} }); // eslint-disable-next-line no-console - console.log('Payment intent result', result2); + console.log('Second payment intent result', result2); } (async () => { diff --git a/packages/payments-plugin/e2e/mollie-payment.e2e-spec.ts b/packages/payments-plugin/e2e/mollie-payment.e2e-spec.ts index 2ca30f3d8b..d7ccb46c24 100644 --- a/packages/payments-plugin/e2e/mollie-payment.e2e-spec.ts +++ b/packages/payments-plugin/e2e/mollie-payment.e2e-spec.ts @@ -3,6 +3,7 @@ import { ChannelService, EventBus, LanguageCode, + Logger, mergeConfig, Order, OrderPlacedEvent, @@ -23,7 +24,7 @@ import { import nock from 'nock'; import fetch from 'node-fetch'; import path from 'path'; -import { afterAll, afterEach, beforeAll, describe, expect, it } from 'vitest'; +import { afterAll, afterEach, beforeAll, describe, expect, it, vi } from 'vitest'; import { initialData } from '../../../e2e-common/e2e-initial-data'; import { testConfig, TEST_SETUP_TIMEOUT_MS } from '../../../e2e-common/test-config'; @@ -215,6 +216,34 @@ describe('Mollie payments', () => { expect(customers).toHaveLength(2); }); + it('Should create a Mollie paymentMethod', async () => { + const { createPaymentMethod } = await adminClient.query< + CreatePaymentMethodMutation, + CreatePaymentMethodMutationVariables + >(CREATE_PAYMENT_METHOD, { + input: { + code: mockData.methodCode, + enabled: true, + handler: { + code: molliePaymentHandler.code, + arguments: [ + { name: 'redirectUrl', value: mockData.redirectUrl }, + { name: 'apiKey', value: mockData.apiKey }, + { name: 'autoCapture', value: 'false' }, + ], + }, + translations: [ + { + languageCode: LanguageCode.en, + name: 'Mollie payment test', + description: 'This is a Mollie test payment method', + }, + ], + }, + }); + expect(createPaymentMethod.code).toBe(mockData.methodCode); + }); + describe('Payment intent creation', () => { it('Should prepare an order', async () => { await shopClient.asUserWithCredentials(customers[0].emailAddress, 'test'); @@ -240,34 +269,6 @@ describe('Mollie payments', () => { expect(order.code).toBeDefined(); }); - it('Should add a Mollie paymentMethod', async () => { - const { createPaymentMethod } = await adminClient.query< - CreatePaymentMethodMutation, - CreatePaymentMethodMutationVariables - >(CREATE_PAYMENT_METHOD, { - input: { - code: mockData.methodCode, - enabled: true, - handler: { - code: molliePaymentHandler.code, - arguments: [ - { name: 'redirectUrl', value: mockData.redirectUrl }, - { name: 'apiKey', value: mockData.apiKey }, - { name: 'autoCapture', value: 'false' }, - ], - }, - translations: [ - { - languageCode: LanguageCode.en, - name: 'Mollie payment test', - description: 'This is a Mollie test payment method', - }, - ], - }, - }); - expect(createPaymentMethod.code).toBe(mockData.methodCode); - }); - it('Should fail to create payment intent without shippingmethod', async () => { await shopClient.asUserWithCredentials(customers[0].emailAddress, 'test'); const { createMolliePaymentIntent: result } = await shopClient.query( @@ -389,45 +390,6 @@ describe('Mollie payments', () => { }); }); - it('Should recreate all order lines in Mollie', async () => { - // Should fetch the existing order from Mollie - nock('https://api.mollie.com/') - .get('/v2/orders/ord_mockId') - .reply(200, mockData.mollieOrderResponse); - // Should patch existing order - nock('https://api.mollie.com/') - .patch(`/v2/orders/${mockData.mollieOrderResponse.id}`) - .reply(200, mockData.mollieOrderResponse); - // Should patch existing order lines - let molliePatchRequest: any | undefined; - nock('https://api.mollie.com/') - .patch(`/v2/orders/${mockData.mollieOrderResponse.id}/lines`, body => { - molliePatchRequest = body; - return true; - }) - .reply(200, mockData.mollieOrderResponse); - const { createMolliePaymentIntent } = await shopClient.query(CREATE_MOLLIE_PAYMENT_INTENT, { - input: { - paymentMethodCode: mockData.methodCode, - }, - }); - expect(createMolliePaymentIntent.url).toBeDefined(); - // Should have removed all 3 previous order lines - const cancelledLines = molliePatchRequest.operations.filter((o: any) => o.operation === 'cancel'); - expect(cancelledLines.length).toBe(3); - // Should have added all 3 new order lines - const addedLines = molliePatchRequest.operations.filter((o: any) => o.operation === 'add'); - expect(addedLines.length).toBe(3); - addedLines.forEach((line: any) => { - expect(line.data).toHaveProperty('name'); - expect(line.data).toHaveProperty('quantity'); - expect(line.data).toHaveProperty('unitPrice'); - expect(line.data).toHaveProperty('totalAmount'); - expect(line.data).toHaveProperty('vatRate'); - expect(line.data).toHaveProperty('vatAmount'); - }); - }); - it('Should get payment url with deducted amount if a payment is already made', async () => { let mollieRequest: any | undefined; nock('https://api.mollie.com/') @@ -566,6 +528,28 @@ describe('Mollie payments', () => { expect(order.state).toBe('PaymentSettled'); }); + it('Should log error when order is paid again with a different mollie order', async () => { + const errorLogSpy = vi.spyOn(Logger, 'error'); + nock('https://api.mollie.com/') + .get('/v2/orders/ord_newMockId') + .reply(200, { + ...mockData.mollieOrderResponse, + id: 'ord_newMockId', + amount: { value: '100', currency: 'EUR' }, // Try to pay another 100 + orderNumber: order.code, + status: OrderStatus.paid, + }); + await fetch(`http://localhost:${serverPort}/payments/mollie/${E2E_DEFAULT_CHANNEL_TOKEN}/1`, { + method: 'post', + body: JSON.stringify({ id: 'ord_newMockId' }), + headers: { 'Content-Type': 'application/json' }, + }); + const logMessage = errorLogSpy.mock.calls?.[0]?.[0]; + expect(logMessage).toBe( + `Order '${order.code}' is already paid. Mollie order 'ord_newMockId' should be refunded.`, + ); + }); + it('Should have preserved original languageCode ', () => { // We've set the languageCode to 'nl' in the mock response's metadata expect(orderPlacedEvent?.ctx.languageCode).toBe('nl'); diff --git a/packages/payments-plugin/src/mollie/custom-fields.ts b/packages/payments-plugin/src/mollie/custom-fields.ts deleted file mode 100644 index a92eb820b6..0000000000 --- a/packages/payments-plugin/src/mollie/custom-fields.ts +++ /dev/null @@ -1,16 +0,0 @@ -import { CustomFieldConfig, Order, CustomOrderFields } from '@vendure/core'; - -export interface OrderWithMollieReference extends Order { - customFields: CustomOrderFields & { - mollieOrderId?: string; - }; -} - -export const orderCustomFields: CustomFieldConfig[] = [ - { - name: 'mollieOrderId', - type: 'string', - internal: true, - nullable: true, - }, -]; diff --git a/packages/payments-plugin/src/mollie/extended-mollie-client.ts b/packages/payments-plugin/src/mollie/extended-mollie-client.ts deleted file mode 100644 index 9cd145ed52..0000000000 --- a/packages/payments-plugin/src/mollie/extended-mollie-client.ts +++ /dev/null @@ -1,76 +0,0 @@ -import createMollieClient, { MollieClient, Order as MollieOrder } from '@mollie/api-client'; -import { Amount } from '@mollie/api-client/dist/types/src/data/global'; -// We depend on the axios dependency from '@mollie/api-client' -import axios, { AxiosInstance } from 'axios'; -import { create } from 'domain'; - -/** - * Create an extended Mollie client that also supports the manage order lines endpoint, because - * the NodeJS client doesn't support it yet. - * - * See https://docs.mollie.com/reference/v2/orders-api/manage-order-lines - * FIXME: Remove this when the NodeJS client supports it. - */ -export function createExtendedMollieClient(options: {apiKey: string}): ExtendedMollieClient { - const client = createMollieClient(options) as ExtendedMollieClient; - // Add our custom method - client.manageOrderLines = async (orderId: string, input: ManageOrderLineInput): Promise => { - const instance = axios.create({ - baseURL: `https://api.mollie.com`, - timeout: 5000, - headers: { - 'Content-Type': 'application/json', - Authorization: `Bearer ${options.apiKey}`, - }, - validateStatus: () => true, // We handle errors ourselves, for better error messages - }); - const {status, data} = await instance.patch(`/v2/orders/${orderId}/lines`, input); - if (status < 200 || status > 300) { - throw Error(JSON.stringify(data, null, 2)) - } - return data; - } - return client; -} - - -export interface ExtendedMollieClient extends MollieClient { - /** - * Update all order lines in 1 request. - */ - manageOrderLines(orderId: string, input: ManageOrderLineInput): Promise; -} - -interface CancelOperation { - operation: 'cancel'; - data: { id: string } -} - -interface UpdateOperation { - operation: 'update'; - data: { - id: string - name?: string - quantity?: number, - unitPrice?: Amount - totalAmount?: Amount - vatRate?: string - vatAmount?: Amount - } -} - -interface AddOperation { - operation: 'add'; - data: { - name: string - quantity: number, - unitPrice: Amount - totalAmount: Amount - vatRate: string - vatAmount: Amount - } -} - -export interface ManageOrderLineInput { - operations: Array -} diff --git a/packages/payments-plugin/src/mollie/mollie.plugin.ts b/packages/payments-plugin/src/mollie/mollie.plugin.ts index 987d10f670..a5b62f4129 100644 --- a/packages/payments-plugin/src/mollie/mollie.plugin.ts +++ b/packages/payments-plugin/src/mollie/mollie.plugin.ts @@ -10,7 +10,6 @@ import { import { shopApiExtensions, adminApiExtensions } from './api-extensions'; import { PLUGIN_INIT_OPTIONS } from './constants'; -import { orderCustomFields } from './custom-fields'; import { MollieCommonResolver } from './mollie.common-resolver'; import { MollieController } from './mollie.controller'; import { molliePaymentHandler } from './mollie.handler'; @@ -195,7 +194,6 @@ export interface MolliePluginOptions { providers: [MollieService, { provide: PLUGIN_INIT_OPTIONS, useFactory: () => MolliePlugin.options }], configuration: (config: RuntimeVendureConfig) => { config.paymentOptions.paymentMethodHandlers.push(molliePaymentHandler); - config.customFields.Order.push(...orderCustomFields); return config; }, shopApiExtensions: { diff --git a/packages/payments-plugin/src/mollie/mollie.service.ts b/packages/payments-plugin/src/mollie/mollie.service.ts index 3288eb3c8d..995fe7b495 100644 --- a/packages/payments-plugin/src/mollie/mollie.service.ts +++ b/packages/payments-plugin/src/mollie/mollie.service.ts @@ -1,4 +1,9 @@ -import { Order as MollieOrder, OrderStatus, PaymentMethod as MollieClientMethod } from '@mollie/api-client'; +import createMollieClient, { + Order as MollieOrder, + OrderStatus, + PaymentMethod as MollieClientMethod, + MollieClient, +} from '@mollie/api-client'; import { CreateParameters } from '@mollie/api-client/dist/types/src/binders/orders/parameters'; import { Inject, Injectable } from '@nestjs/common'; import { ModuleRef } from '@nestjs/core'; @@ -25,12 +30,6 @@ import { OrderStateMachine } from '@vendure/core/'; import { totalCoveredByPayments } from '@vendure/core/dist/service/helpers/utils/order-utils'; import { loggerCtx, PLUGIN_INIT_OPTIONS } from './constants'; -import { OrderWithMollieReference } from './custom-fields'; -import { - createExtendedMollieClient, - ExtendedMollieClient, - ManageOrderLineInput, -} from './extended-mollie-client'; import { ErrorCode, MolliePaymentIntentError, @@ -69,7 +68,6 @@ export class MollieService { private activeOrderService: ActiveOrderService, private orderService: OrderService, private entityHydrator: EntityHydrator, - private variantService: ProductVariantService, private moduleRef: ModuleRef, ) { this.injector = new Injector(this.moduleRef); @@ -154,7 +152,7 @@ export class MollieService { ); return new PaymentIntentError(`Paymentmethod ${paymentMethod.code} has no apiKey configured`); } - const mollieClient = createExtendedMollieClient({ apiKey }); + const mollieClient = createMollieClient({ apiKey }); const vendureHost = this.options.vendureHost.endsWith('/') ? this.options.vendureHost.slice(0, -1) : this.options.vendureHost; // remove appending slash @@ -207,31 +205,6 @@ export class MollieService { if (molliePaymentMethodCode) { orderInput.method = molliePaymentMethodCode as MollieClientMethod; } - const existingMollieOrderId = (order as OrderWithMollieReference).customFields.mollieOrderId; - if (existingMollieOrderId) { - // Update order and return its checkoutUrl - const updateMollieOrder = await this.updateMollieOrder( - mollieClient, - orderInput, - existingMollieOrderId, - ).catch(e => { - Logger.error( - `Failed to update Mollie order '${existingMollieOrderId}' for '${order.code}': ${(e as Error).message}`, - loggerCtx, - ); - }); - const checkoutUrl = updateMollieOrder?.getCheckoutUrl(); - if (checkoutUrl) { - Logger.info( - `Updated Mollie order '${updateMollieOrder?.id as string}' for order '${order.code}'`, - loggerCtx, - ); - return { - url: checkoutUrl, - }; - } - } - // Otherwise create a new Mollie order const mollieOrder = await mollieClient.orders.create(orderInput); // Save async, because this shouldn't impact intent creation this.orderService.updateCustomFields(ctx, order.id, { mollieOrderId: mollieOrder.id }).catch(e => { @@ -268,7 +241,7 @@ export class MollieService { if (!apiKey) { throw Error(`No apiKey found for payment ${paymentMethod.id} for channel ${ctx.channel.token}`); } - const client = createExtendedMollieClient({ apiKey }); + const client = createMollieClient({ apiKey }); const mollieOrder = await client.orders.get(orderId); if (mollieOrder.metadata?.languageCode) { // Recreate ctx with the original languageCode @@ -291,6 +264,19 @@ export class MollieService { `Unable to find order ${mollieOrder.orderNumber}, unable to process Mollie order ${mollieOrder.id}`, ); } + if (order.orderPlacedAt) { + const paymentWithSameTransactionId = order.payments.find( + p => p.transactionId === mollieOrder.id && p.state === 'Settled', + ); + if (!paymentWithSameTransactionId) { + // The order is paid for again, with another transaction ID. This means the customer paid twice + Logger.error( + `Order '${order.code}' is already paid. Mollie order '${mollieOrder.id}' should be refunded.`, + loggerCtx, + ); + return; + } + } const statesThatRequireAction: OrderState[] = [ 'AddingItems', 'ArrangingPayment', @@ -414,7 +400,7 @@ export class MollieService { throw Error(`No apiKey configured for payment method ${paymentMethodCode}`); } - const client = createExtendedMollieClient({ apiKey }); + const client = createMollieClient({ apiKey }); const activeOrder = await this.activeOrderService.getActiveOrder(ctx, undefined); const additionalParams = await this.options.enabledPaymentMethodsParams?.( this.injector, @@ -433,80 +419,6 @@ export class MollieService { })); } - async getVariantsWithInsufficientStock(ctx: RequestContext, order: Order): Promise { - const variantsWithInsufficientSaleableStock: ProductVariant[] = []; - for (const line of order.lines) { - const availableStock = await this.variantService.getSaleableStockLevel(ctx, line.productVariant); - if (line.quantity > availableStock) { - variantsWithInsufficientSaleableStock.push(line.productVariant); - } - } - return variantsWithInsufficientSaleableStock; - } - - /** - * Update an existing Mollie order based on the given Vendure order. - */ - async updateMollieOrder( - mollieClient: ExtendedMollieClient, - newMollieOrderInput: CreateParameters, - mollieOrderId: string, - ): Promise { - const existingMollieOrder = await mollieClient.orders.get(mollieOrderId); - const [order] = await Promise.all([ - this.updateMollieOrderData(mollieClient, existingMollieOrder, newMollieOrderInput), - this.updateMollieOrderLines(mollieClient, existingMollieOrder, newMollieOrderInput.lines), - ]); - return order; - } - - /** - * Update the Mollie Order data itself, excluding the order lines. - * So, addresses, redirect url etc - */ - private async updateMollieOrderData( - mollieClient: ExtendedMollieClient, - existingMollieOrder: MollieOrder, - newMollieOrderInput: CreateParameters, - ): Promise { - return await mollieClient.orders.update(existingMollieOrder.id, { - billingAddress: newMollieOrderInput.billingAddress, - shippingAddress: newMollieOrderInput.shippingAddress, - redirectUrl: newMollieOrderInput.redirectUrl, - }); - } - - /** - * Delete all order lines of current Mollie order, and create new ones based on the new Vendure order lines - */ - private async updateMollieOrderLines( - mollieClient: ExtendedMollieClient, - existingMollieOrder: MollieOrder, - /** - * These are the new order lines based on the Vendure order - */ - newMollieOrderLines: CreateParameters['lines'], - ): Promise { - const manageOrderLinesInput: ManageOrderLineInput = { - operations: [], - }; - // Cancel all previous order lines and create new ones - existingMollieOrder.lines.forEach(existingLine => { - manageOrderLinesInput.operations.push({ - operation: 'cancel', - data: { id: existingLine.id }, - }); - }); - // Add new order lines - newMollieOrderLines.forEach(newLine => { - manageOrderLinesInput.operations.push({ - operation: 'add', - data: newLine, - }); - }); - return await mollieClient.manageOrderLines(existingMollieOrder.id, manageOrderLinesInput); - } - /** * Dry run a transition to a given state. * As long as we don't call 'finalize', the transition never completes.