Bläddra i källkod

fix(payments-plugin): Fix Mollie not calling webhook on updated orders (#3014)

Fixes #2941
Martijn 1 år sedan
förälder
incheckning
694845f8e6

+ 0 - 0
packages/payments-plugin/e2e/stripe-checkout-test.plugin.ts → packages/payments-plugin/e2e/fixtures/stripe-checkout-test.plugin.ts


+ 2 - 6
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 () => {

+ 52 - 68
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');

+ 0 - 16
packages/payments-plugin/src/mollie/custom-fields.ts

@@ -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,
-    },
-];

+ 0 - 76
packages/payments-plugin/src/mollie/extended-mollie-client.ts

@@ -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<MollieOrder> => {
-        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<MollieOrder>;
-}
-
-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<CancelOperation | AddOperation | UpdateOperation>
-}

+ 0 - 2
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: {

+ 22 - 110
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<ProductVariant[]> {
-        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<MollieOrder> {
-        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<MollieOrder> {
-        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<MollieOrder> {
-        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.