Browse Source

feat(payments-plugin): Allow forcefully updating payment status in case webhooks are delayed (#4104)

Thanks for the contribution Martijn! 🙏
Martijn 4 days ago
parent
commit
59ba50113e

+ 0 - 1
packages/payments-plugin/e2e/graphql/admin-queries.ts

@@ -1,4 +1,3 @@
-import { CHANNEL_FRAGMENT } from '@vendure/core/e2e/graphql/fragments';
 import gql from 'graphql-tag';
 
 export const PAYMENT_METHOD_FRAGMENT = gql`

+ 10 - 0
packages/payments-plugin/e2e/graphql/shared-definitions.ts

@@ -48,3 +48,13 @@ export const createCustomStripePaymentIntentDocument = graphql(`
         createCustomStripePaymentIntent
     }
 `);
+
+export const syncMolliePaymentStatusDocument = graphql(`
+    mutation syncMolliePaymentStatus($orderCode: String!) {
+        syncMolliePaymentStatus(orderCode: $orderCode) {
+            id
+            code
+            state
+        }
+    }
+`);

+ 34 - 47
packages/payments-plugin/e2e/mollie-dev-server.ts

@@ -1,5 +1,5 @@
 import { AdminUiPlugin } from '@vendure/admin-ui-plugin';
-import { DefaultLogger, DefaultSearchPlugin, LogLevel, mergeConfig } from '@vendure/core';
+import { DefaultLogger, DefaultSearchPlugin, LanguageCode, LogLevel, mergeConfig } from '@vendure/core';
 import { createTestEnvironment, registerInitializer, SqljsInitializer, testConfig } from '@vendure/testing';
 import gql from 'graphql-tag';
 import localtunnel from 'localtunnel';
@@ -10,13 +10,8 @@ import { molliePaymentHandler } from '../package/mollie/mollie.handler';
 import { MolliePlugin } from '../src/mollie';
 
 import { CREATE_PAYMENT_METHOD } from './graphql/admin-queries';
-import {
-    CreatePaymentMethodMutation,
-    CreatePaymentMethodMutationVariables,
-    LanguageCode,
-} from './graphql/generated-admin-types';
 import { ADD_ITEM_TO_ORDER, APPLY_COUPON_CODE } from './graphql/shop-queries';
-import { CREATE_MOLLIE_PAYMENT_INTENT, setShipping } from './payment-helpers';
+import { setShipping } from './payment-helpers';
 
 /**
  * This should only be used to locally test the Mollie payment plugin
@@ -61,34 +56,30 @@ async function runMollieDevServer() {
         }
     `);
     // Create method
-    await adminClient.query<CreatePaymentMethodMutation, CreatePaymentMethodMutationVariables>(
-        CREATE_PAYMENT_METHOD,
-        {
-            input: {
-                code: 'mollie',
-                translations: [
+    await adminClient.query(CREATE_PAYMENT_METHOD, {
+        input: {
+            code: 'mollie',
+            translations: [
+                {
+                    languageCode: LanguageCode.en,
+                    name: 'Mollie payment test',
+                    description: 'This is a Mollie test payment method',
+                },
+            ],
+            enabled: true,
+            handler: {
+                code: molliePaymentHandler.code,
+                arguments: [
                     {
-                        languageCode: LanguageCode.en,
-                        name: 'Mollie payment test',
-                        description: 'This is a Mollie test payment method',
+                        name: 'redirectUrl',
+                        value: `${tunnel.url}/admin/orders?filter=open&page=1`,
                     },
+                    // eslint-disable-next-line @typescript-eslint/no-non-null-assertion
+                    { name: 'apiKey', value: process.env.MOLLIE_APIKEY! },
                 ],
-                enabled: true,
-                handler: {
-                    code: molliePaymentHandler.code,
-                    arguments: [
-                        {
-                            name: 'redirectUrl',
-                            value: `${tunnel.url}/admin/orders?filter=open&page=1`,
-                        },
-                        // eslint-disable-next-line @typescript-eslint/no-non-null-assertion
-                        { name: 'apiKey', value: process.env.MOLLIE_APIKEY! },
-                        { name: 'autoCapture', value: 'false' },
-                    ],
-                },
             },
         },
-    );
+    });
     // Prepare a test order where the total is 0
     await shopClient.asUserWithCredentials('hayden.zieme12@hotmail.com', 'test');
     await shopClient.query(ADD_ITEM_TO_ORDER, {
@@ -102,30 +93,26 @@ async function runMollieDevServer() {
     const result = await shopClient.query(CREATE_MOLLIE_PAYMENT_INTENT, {
         input: {
             locale: 'nl_NL',
-            // immediateCapture: true,
+            immediateCapture: false,
         },
     });
     // eslint-disable-next-line no-console
     console.log('Payment intent result', result);
 
-    // Add another item to the order
-    await shopClient.query(ADD_ITEM_TO_ORDER, {
-        productVariantId: 'T_1',
-        quantity: 1,
-    });
-
-    // Wait X seconds and create another payment intent
-    await new Promise(resolve => setTimeout(resolve, 20000));
-
-    const result2 = await shopClient.query(CREATE_MOLLIE_PAYMENT_INTENT, {
-        input: {
-            locale: 'nl_NL',
-        },
-    });
-    // eslint-disable-next-line no-console
-    console.log('Second payment intent result', result2);
+    // Uncomme this line to disable webhook processing and test the `syncMolliePaymentStatus` mutation
+    // MolliePlugin.options.disableWebhookProcessing = true;
 }
 
 (async () => {
     await runMollieDevServer();
 })();
+
+const CREATE_MOLLIE_PAYMENT_INTENT = gql`
+    mutation createMolliePaymentIntent($input: MolliePaymentIntentInput!) {
+        createMolliePaymentIntent(input: $input) {
+            ... on MolliePaymentIntent {
+                url
+            }
+        }
+    }
+`;

+ 60 - 2
packages/payments-plugin/e2e/mollie-payment.e2e-spec.ts

@@ -41,6 +41,7 @@ import { FragmentOf, ResultOf } from './graphql/graphql-admin';
 import {
     createMolliePaymentIntentDocument,
     getMolliePaymentMethodsDocument,
+    syncMolliePaymentStatusDocument,
 } from './graphql/shared-definitions';
 import {
     addItemToOrderDocument,
@@ -122,7 +123,6 @@ describe('Mollie payments', () => {
                     arguments: [
                         { name: 'redirectUrl', value: mollieMockData.redirectUrl },
                         { name: 'apiKey', value: mollieMockData.apiKey },
-                        { name: 'autoCapture', value: 'false' },
                     ],
                 },
                 translations: [
@@ -346,7 +346,7 @@ describe('Mollie payments', () => {
 
         it('Should get available paymentMethods', async () => {
             nock('https://api.mollie.com/')
-                .get('/v2/methods?resource=orders')
+                .get('/v2/methods?resource=payments')
                 .reply(200, mollieMockData.molliePaymentMethodsResponse);
             await shopClient.asUserWithCredentials(customers[0].emailAddress, 'test');
             const { molliePaymentMethods } = await shopClient.query(getMolliePaymentMethodsDocument, {
@@ -614,4 +614,62 @@ describe('Mollie payments', () => {
             expect(order.state).toBe('PaymentSettled');
         });
     });
+
+    describe('Force status update when no webhook is received', () => {
+        it('Should prepare a new order', async () => {
+            await shopClient.asUserWithCredentials(customers[0].emailAddress, 'test');
+            const { addItemToOrder } = await shopClient.query(addItemToOrderDocument, {
+                productVariantId: 'T_1',
+                quantity: 2,
+            });
+            order = addItemToOrder as FragmentOf<typeof testOrderFragment>;
+            await setShipping(shopClient);
+            expect(order.totalWithTax).toBe(311760);
+            expect(order.code).toBeDefined();
+            expect(order.state).toBe('AddingItems');
+        });
+
+        // Instead of receiving a webhook, we make Vendure fetch payments from Mollie manually and update the order status accordingly
+        it('Syncs status based on Mollie payment', async () => {
+            // Mock the payments list endpoint (used by iterator to find payments for the order)
+            nock('https://api.mollie.com/')
+                .get('/v2/payments')
+                .query(true)
+                .reply(200, {
+                    count: 1,
+                    _embedded: {
+                        payments: [
+                            {
+                                ...mollieMockData.molliePaymentResponse,
+                                id: 'tr_syncTestPayment',
+                                description: order.code,
+                                status: OrderStatus.paid,
+                            },
+                        ],
+                    },
+                    _links: {
+                        self: {
+                            href: 'https://api.mollie.com/v2/payments',
+                            type: 'application/hal+json',
+                        },
+                    },
+                });
+            // Mock the individual payment GET endpoint (used by handleMolliePaymentStatus to get the payment details)
+            nock('https://api.mollie.com/')
+                .get('/v2/payments/tr_syncTestPayment')
+                .reply(200, {
+                    ...mollieMockData.molliePaymentResponse,
+                    id: 'tr_syncTestPayment',
+                    description: order.code,
+                    status: OrderStatus.paid,
+                    amount: { value: '3127.60', currency: 'EUR' },
+                });
+            // Call the sync mutation
+            const { syncMolliePaymentStatus } = await shopClient.query<any>(syncMolliePaymentStatusDocument, {
+                orderCode: order.code,
+            });
+            expect(syncMolliePaymentStatus.state).toBe('PaymentSettled');
+            expect(syncMolliePaymentStatus.code).toBe(order.code);
+        });
+    });
 });

+ 6 - 0
packages/payments-plugin/src/mollie/api-extensions.ts

@@ -50,6 +50,12 @@ const commonSchemaExtensions = gql`
 
     extend type Mutation {
         createMolliePaymentIntent(input: MolliePaymentIntentInput!): MolliePaymentIntentResult!
+        """
+        Fetch the payment status from Mollie and update the order status in Vendure accordingly.
+        Use this mutation when the Mollie webhook is delayed and you want to manually force update the order status.
+        Throws a ForbiddenError for unauthenticated calls when the order is not yet settled.
+        """
+        syncMolliePaymentStatus(orderCode: String!): Order
     }
 `;
 

+ 10 - 0
packages/payments-plugin/src/mollie/graphql/generated-shop-types.ts

@@ -1932,6 +1932,12 @@ export type Mutation = {
     updateCustomerEmailAddress: UpdateCustomerEmailAddressResult;
     /** Update the password of the active Customer */
     updateCustomerPassword: UpdateCustomerPasswordResult;
+    /**
+     * Fetch the payment status from Mollie and update the order status in Vendure accordingly.
+     * Use this mutation when the Mollie webhook is delayed and you want to manually force update the order status.
+     * Throws a ForbiddenError for unauthenticated calls when the order is not yet settled.
+     */
+    syncMolliePaymentStatus?: Maybe<Order>;
     /**
      * Verify a Customer email address with the token sent to that address. Only applicable if `authOptions.requireVerification` is set to true.
      *
@@ -2057,6 +2063,10 @@ export type MutationUpdateCustomerPasswordArgs = {
     newPassword: Scalars['String']['input'];
 };
 
+export type MutationSyncMolliePaymentStatusArgs = {
+    orderCode: Scalars['String']['input'];
+};
+
 export type MutationVerifyCustomerAccountArgs = {
     password?: InputMaybe<Scalars['String']['input']>;
     token: Scalars['String']['input'];

+ 11 - 2
packages/payments-plugin/src/mollie/mollie.common-resolver.ts

@@ -1,11 +1,11 @@
 import { Args, Mutation, ResolveField, Resolver } from '@nestjs/graphql';
-import { Allow, Ctx, Permission, RequestContext } from '@vendure/core';
+import { Allow, Ctx, Order, Permission, RequestContext } from '@vendure/core';
 
 import {
     MolliePaymentIntent,
     MolliePaymentIntentError,
     MolliePaymentIntentInput,
-    MolliePaymentIntentResult
+    MolliePaymentIntentResult,
 } from './graphql/generated-shop-types';
 import { MollieService } from './mollie.service';
 
@@ -22,6 +22,15 @@ export class MollieCommonResolver {
         return this.mollieService.createPaymentIntent(ctx, input);
     }
 
+    @Mutation()
+    @Allow(Permission.Owner)
+    async syncMolliePaymentStatus(
+        @Ctx() ctx: RequestContext,
+        @Args('orderCode') orderCode: string,
+    ): Promise<Order | undefined> {
+        return this.mollieService.syncMolliePaymentStatus(ctx, orderCode);
+    }
+
     @ResolveField()
     @Resolver('MolliePaymentIntentResult')
     __resolveType(value: MolliePaymentIntentError | MolliePaymentIntent): string {

+ 12 - 4
packages/payments-plugin/src/mollie/mollie.controller.ts

@@ -1,8 +1,9 @@
-import { Body, Controller, Param, Post, Req } from '@nestjs/common';
+import { Body, Controller, Inject, Param, Post, Req } from '@nestjs/common';
 import { ChannelService, LanguageCode, Logger, RequestContext, Transaction } from '@vendure/core';
 import { Request } from 'express';
 
-import { loggerCtx } from './constants';
+import { loggerCtx, PLUGIN_INIT_OPTIONS } from './constants';
+import { MolliePluginOptions } from './mollie.plugin';
 import { MollieService } from './mollie.service';
 
 @Controller('payments')
@@ -10,6 +11,7 @@ export class MollieController {
     constructor(
         private mollieService: MollieService,
         private channelService: ChannelService,
+        @Inject(PLUGIN_INIT_OPTIONS) private options: MolliePluginOptions,
     ) {}
 
     @Post('mollie/:channelToken/:paymentMethodId')
@@ -20,14 +22,20 @@ export class MollieController {
         @Body() body: any,
         @Req() req: Request,
     ): Promise<void> {
+        if (this.options.disableWebhookProcessing) {
+            return Logger.warn(
+                `Webhook processing is disabled, ignoring incoming webhook '${String(body?.id)}'`,
+                loggerCtx,
+            );
+        }
         if (!body.id) {
-            return Logger.warn(' Ignoring incoming webhook, because it has no body.id.', loggerCtx);
+            return Logger.warn('Ignoring incoming webhook, because it has no body.id.', loggerCtx);
         }
         try {
             // We need to construct a RequestContext based on the channelToken,
             // because this is an incoming webhook, not a graphql request with a valid Ctx
             const ctx = await this.createContext(channelToken, req);
-            await this.mollieService.handleMollieStatusUpdate(ctx, {
+            await this.mollieService.handleMolliePaymentStatus(ctx, {
                 paymentMethodId,
                 paymentId: body.id,
             });

+ 48 - 20
packages/payments-plugin/src/mollie/mollie.plugin.ts

@@ -74,6 +74,14 @@ export interface MolliePluginOptions {
         ctx: RequestContext,
         order: Order | null,
     ) => AdditionalEnabledPaymentMethodsParams | Promise<AdditionalEnabledPaymentMethodsParams>;
+    /**
+     * @description
+     * Disable the processing of incoming Mollie webhooks.
+     * Handle with care! This will keep orders in 'AddingItems' state if you don't manually process the Mollie payments via the `syncMolliePaymentStatus` mutation.
+     *
+     * @since 3.6.0
+     */
+    disableWebhookProcessing?: boolean;
 }
 
 /**
@@ -81,9 +89,9 @@ export interface MolliePluginOptions {
  * Plugin to enable payments through the [Mollie platform](https://docs.mollie.com/).
  * This plugin uses the Order API from Mollie, not the Payments API.
  *
- * ## Requirements
+ * ### Requirements
  *
- * 1. You will need to create a Mollie account and get your apiKey in the dashboard.
+ * 1. You will need to create a Mollie account and get your api key from the Mollie dashboard.
  * 2. Install the Payments plugin and the Mollie client:
  *
  *     `yarn add \@vendure/payments-plugin \@mollie/api-client`
@@ -92,7 +100,7 @@ export interface MolliePluginOptions {
  *
  *     `npm install \@vendure/payments-plugin \@mollie/api-client`
  *
- * ## Setup
+ * ### Setup
  *
  * 1. Add the plugin to your VendureConfig `plugins` array:
  *     ```ts
@@ -104,16 +112,16 @@ export interface MolliePluginOptions {
  *       MolliePlugin.init({ vendureHost: 'https://yourhost.io/' }),
  *     ]
  *     ```
- * 2. Create a new PaymentMethod in the Admin UI, and select "Mollie payments" as the handler.
+ * 2. Create a new payment method in the Admin UI, and select "Mollie payments" as the handler.
  * 3. Set your Mollie apiKey in the `API Key` field.
  * 4. Set the `Fallback redirectUrl` to the url that the customer should be redirected to after completing the payment.
  * You can override this url by passing the `redirectUrl` as an argument to the `createMolliePaymentIntent` mutation.
  *
- * ## Storefront usage
+ * ### Storefront usage
  *
  * In your storefront you add a payment to an order using the `createMolliePaymentIntent` mutation. In this example, our Mollie
- * PaymentMethod was given the code "mollie-payment-method". The `redirectUrl``is the url that is used to redirect the end-user
- * back to your storefront after completing the payment.
+ * payment method was given the code "mollie-payment-method". The `redirectUrl` should be your order confirmation page.
+ * It is the url that is used to redirect the customer back to your storefront after completing the payment.
  *
  * ```GraphQL
  * mutation CreateMolliePaymentIntent {
@@ -135,11 +143,9 @@ export interface MolliePluginOptions {
  * }
  * ```
  *
- * The response will contain
- * a redirectUrl, which can be used to redirect your customer to the Mollie
- * platform.
+ * You can use `molliePaymentIntent.url` to redirect the customer to the Mollie platform.
  *
- * 'molliePaymentMethodCode' is an optional parameter that can be passed to skip Mollie's hosted payment method selection screen
+ * The `molliePaymentMethodCode` is an optional parameter that can be passed to preselect a payment method, and skip Mollie's payment method selection screen
  * You can get available Mollie payment methods with the following query:
  *
  * ```GraphQL
@@ -166,9 +172,32 @@ export interface MolliePluginOptions {
  * ```
  *
  * After completing payment on the Mollie platform,
- * the user is redirected to the redirect url that was provided in the `createMolliePaymentIntent` mutation, e.g. `https://storefront/order/CH234X5`
+ * the user is redirected by Mollie to the provided redirect url (confirmation page).
+ * E.g. `https://storefront/order/`. The redirect url here was `https://storefront/order`, the order code `CH234X5` is appended automatically by the plugin.
+ *
+ * #### Force payment status update
+ *
+ * Mollie does not give any guarantees on webhook delivery time, and in some rare cases,
+ * the Mollie webhook is delayed and the order status is not updated in Vendure.
+ *
+ * You can use the `syncMolliePaymentStatus` mutation to force update the order status based on the Mollie payment status.
+ * This mutation will find any settled or authorized Mollie payments for the given order and update the order status in Vendure accordingly.
+ *
+ * ```GraphQL
+ * mutation SyncMolliePaymentStatus {
+ *   syncMolliePaymentStatus(orderCode: "CH234X5") {
+ *     id
+ *     state
+ *   }
+ * }
+ * ```
+ *
+ * You should wait for an incoming webhook first, because due to technical limitations on the Mollie API, the `syncMolliePaymentStatus`
+ * mutation will iterate through the last 500 Mollie payments to find the payments for the given order.
+ * Hence, it is not very performant, and should only be used as a fallback when a webhook
+ * was not received for ~10 seconds.
  *
- * ## Pay later methods
+ * ### Pay later methods
  *
  * Mollie supports pay-later methods like 'Klarna Pay Later'. Pay-later methods are captured immediately after payment.
  *
@@ -176,17 +205,16 @@ export interface MolliePluginOptions {
  * This will transition your order to 'PaymentAuthorized' after the Mollie hosted checkout.
  * You need to manually capture the payment after the order is fulfilled, by settling existing payments, either via the admin UI or in custom code.
  *
- * Make sure to capture a payment within 28 days, after that the payment will be automaticallreleased.
+ * Make sure to capture a payment within 28 days, after that the payment will be automatically released.
  * See the [Mollie documentation](https://docs.mollie.com/docs/place-a-hold-for-a-payment#authorization-expiration-window)
  * for more information.
  *
- * ## ArrangingAdditionalPayment state
+ * ### ArrangingAdditionalPayment state
  *
- * In some rare cases, a customer can add items to the active order, while a Mollie checkout is still open,
- * for example by opening your storefront in another browser tab.
- * This could result in an order being in `ArrangingAdditionalPayment` status after the customer finished payment.
- * You should check if there is still an active order with status `ArrangingAdditionalPayment` on your order confirmation page,
- * and if so, allow your customer to pay for the additional items by creating another Mollie payment.
+ * In some cases, a customer can add items to the active order, while a Mollie checkout is still open, or an administrator can modify an order.
+ * Both of these actions will result in an order being in `ArrangingAdditionalPayment` status.
+ * To finalize an order in `ArrangingAdditionalPayment` status, you can use call the `createMolliePaymentIntent` mutation again with an additional `orderId` as input.
+ * The `orderId` argument is needed, because an order in `ArrangingAdditionalPayment` status is not an active order anymore.
  *
  * @docsCategory core plugins/PaymentsPlugin
  * @docsPage MolliePlugin

+ 124 - 38
packages/payments-plugin/src/mollie/mollie.service.ts

@@ -1,6 +1,7 @@
 import createMollieClient, {
     CaptureMethod,
     Locale,
+    MollieClient,
     PaymentMethod as MollieClientMethod,
     PaymentStatus,
 } from '@mollie/api-client';
@@ -10,22 +11,25 @@ import { ModuleRef } from '@nestjs/core';
 import {
     ActiveOrderService,
     assertFound,
+    ConfigService,
     EntityHydrator,
     ErrorResult,
+    ForbiddenError,
     ID,
     idsAreEqual,
     Injector,
     LanguageCode,
     Logger,
+    LogLevel,
     Order,
     OrderService,
     OrderState,
+    OrderStateMachine,
     OrderStateTransitionError,
     PaymentMethod,
     PaymentMethodService,
     RequestContext,
 } from '@vendure/core';
-import { OrderStateMachine } from '@vendure/core/';
 import { totalCoveredByPayments } from '@vendure/core/dist/service/helpers/utils/order-utils';
 
 import { loggerCtx, PLUGIN_INIT_OPTIONS } from './constants';
@@ -42,7 +46,7 @@ import { MolliePluginOptions } from './mollie.plugin';
 import { MolliePaymentMetadata } from './types';
 
 interface OrderStatusInput {
-    paymentMethodId: string;
+    paymentMethodId: ID;
     paymentId: string;
 }
 
@@ -58,6 +62,17 @@ class InvalidInputError implements MolliePaymentIntentError {
     constructor(public message: string) {}
 }
 
+/**
+ * If order is not in one of these states, we don't need to handle any incoming status update from Mollie
+ */
+const VENDURE_STATES_THAT_REQUIRE_ACTION: OrderState[] = [
+    'AddingItems',
+    'ArrangingPayment',
+    'ArrangingAdditionalPayment',
+    'PaymentAuthorized',
+    'Draft',
+];
+
 @Injectable()
 export class MollieService {
     private readonly injector: Injector;
@@ -69,6 +84,7 @@ export class MollieService {
         private orderService: OrderService,
         private entityHydrator: EntityHydrator,
         private moduleRef: ModuleRef,
+        private configService: ConfigService,
     ) {
         this.injector = new Injector(this.moduleRef);
     }
@@ -219,18 +235,16 @@ export class MollieService {
     /**
      * Update Vendure payments and order status based on the incoming Mollie payment
      */
-    async handleMollieStatusUpdate(
+    async handleMolliePaymentStatus(
         ctx: RequestContext,
         { paymentMethodId, paymentId }: OrderStatusInput,
-    ): Promise<void> {
-        Logger.info(
-            `Received status update for channel ${ctx.channel.token} for Mollie payment ${paymentId}`,
-            loggerCtx,
-        );
+    ): Promise<Order | undefined> {
+        Logger.info(`Processing Mollie payment '${paymentId}' for channel ${ctx.channel.token}`, loggerCtx);
         const paymentMethod = await this.paymentMethodService.findOne(ctx, paymentMethodId);
         if (!paymentMethod) {
             // Fail silently, as we don't want to expose if a paymentMethodId exists or not
-            return Logger.warn(`No paymentMethod found with id ${paymentMethodId}`, loggerCtx);
+            Logger.warn(`No paymentMethod found with id ${paymentMethodId}`, loggerCtx);
+            return;
         }
         const apiKey = paymentMethod.handler.args.find(a => a.name === 'apiKey')?.value;
         if (!apiKey) {
@@ -251,7 +265,7 @@ export class MollieService {
             });
         }
         Logger.info(
-            `Processing incoming webhook status '${molliePayment.status}' for order ${
+            `Processing Mollie payment status '${molliePayment.status}' for order ${
                 molliePayment.description
             } for channel ${ctx.channel.token} for Mollie payment ${paymentId}`,
             loggerCtx,
@@ -264,12 +278,12 @@ export class MollieService {
         }
         const mollieStatesThatRequireAction: PaymentStatus[] = [PaymentStatus.authorized, PaymentStatus.paid];
         if (!mollieStatesThatRequireAction.includes(molliePayment.status)) {
-            // No need to handle this mollie webhook status
+            // No need to handle this mollie status
             Logger.info(
-                `Ignoring Mollie status '${molliePayment.status}' from incoming webhook for '${order.code}'`,
+                `Ignoring Mollie status '${molliePayment.status}' for order '${order.code}'`,
                 loggerCtx,
             );
-            return;
+            return order;
         }
         if (order.orderPlacedAt) {
             const paymentWithSameTransactionId = order.payments.find(
@@ -281,7 +295,7 @@ export class MollieService {
                     `Order '${order.code}' is already paid. Mollie payment '${molliePayment.id}' should be refunded.`,
                     loggerCtx,
                 );
-                return;
+                return order;
             }
         }
         if (order.state === 'Cancelled' && molliePayment.status === PaymentStatus.paid) {
@@ -291,22 +305,15 @@ export class MollieService {
                 }' should be refunded.`,
                 loggerCtx,
             );
-            return;
+            return order;
         }
-        // If order is not in one of these states, we don't need to handle the Mollie webhook
-        const vendureStatesThatRequireAction: OrderState[] = [
-            'AddingItems',
-            'ArrangingPayment',
-            'ArrangingAdditionalPayment',
-            'PaymentAuthorized',
-            'Draft',
-        ];
-        if (!vendureStatesThatRequireAction.includes(order.state)) {
+
+        if (!VENDURE_STATES_THAT_REQUIRE_ACTION.includes(order.state)) {
             Logger.info(
                 `Order ${order.code} is already '${order.state}', no need for handling Mollie status '${molliePayment.status}'`,
                 loggerCtx,
             );
-            return;
+            return order;
         }
         const amount = amountToCents(molliePayment.amount);
         // Metadata to add to a payment
@@ -320,11 +327,12 @@ export class MollieService {
         };
         if (order.state === 'PaymentAuthorized' && molliePayment.status === PaymentStatus.paid) {
             // If our order is in PaymentAuthorized state, it means a 2 step payment was used (E.g. a pay-later method like Klarna)
-            return this.settleExistingPayment(ctx, order, molliePayment.id);
+            await this.settleExistingPayment(ctx, order, molliePayment.id);
+            return await this.orderService.findOne(ctx, order.id);
         }
         if (molliePayment.status === PaymentStatus.paid) {
             await this.addPayment(ctx, order, amount, mollieMetadata, paymentMethod.code, 'Settled');
-            return;
+            return await this.orderService.findOne(ctx, order.id);
         }
         if (order.state === 'AddingItems' && molliePayment.status === PaymentStatus.authorized) {
             // Transition order to PaymentAuthorized by creating an authorized payment
@@ -336,7 +344,7 @@ export class MollieService {
                 paymentMethod.code,
                 'Authorized',
             );
-            return;
+            return await this.orderService.findOne(ctx, order.id);
         }
         // Any other combination of Mollie status and Vendure status indicates something is wrong.
         throw Error(
@@ -413,24 +421,16 @@ export class MollieService {
         ctx: RequestContext,
         paymentMethodCode: string,
     ): Promise<MolliePaymentMethod[]> {
-        const paymentMethod = await this.getPaymentMethod(ctx, paymentMethodCode);
-        const apiKey = paymentMethod?.handler.args.find(arg => arg.name === 'apiKey')?.value;
-        if (!apiKey) {
-            throw Error(`No apiKey configured for payment method ${paymentMethodCode}`);
-        }
-
-        const client = createMollieClient({ apiKey });
+        const [client] = await this.getMollieClient(ctx, paymentMethodCode);
         const activeOrder = await this.activeOrderService.getActiveOrder(ctx, undefined);
         const additionalParams = await this.options.enabledPaymentMethodsParams?.(
             this.injector,
             ctx,
             activeOrder ?? null,
         );
-
-        // We use the orders API, so list available methods for that API usage
         const methods = await client.methods.list({
             ...additionalParams,
-            resource: 'orders',
+            resource: 'payments',
         });
         return methods.map(m => ({
             ...m,
@@ -438,6 +438,89 @@ export class MollieService {
         }));
     }
 
+    /**
+     * Fetches the payment from Mollie, and updates the status of the order in Vendure based on the Mollie payment status
+     */
+    async syncMolliePaymentStatus(ctx: RequestContext, orderCode: string): Promise<Order> {
+        let order = await this.orderService.findOneByCode(ctx, orderCode);
+        if (!order) {
+            throw new ForbiddenError(LogLevel.Verbose);
+        }
+        if (!VENDURE_STATES_THAT_REQUIRE_ACTION.includes(order.state)) {
+            Logger.info(
+                `syncMolliePaymentStatus: Order ${order.code} is already '${order.state}', no need to fetch Mollie payments.`,
+                loggerCtx,
+            );
+            return order;
+        }
+        const originalOrderState = order.state;
+        const [mollieClient, paymentMethod] = await this.getMollieClient(ctx);
+        // Find payments for orderCode that are authorized or paid
+        const processedPaymentIds: string[] = [];
+        let count = 0;
+        const MAX_PAYMENTS = 500; // Max payments to prevent looping over ALL payments in the Mollie
+        for await (const payment of mollieClient.payments.iterate()) {
+            if (count++ >= MAX_PAYMENTS) {
+                Logger.warn(
+                    `syncMolliePaymentStatus: Stopping after processing ${MAX_PAYMENTS} payments for order '${order.code}' to avoid indefinite looping.`,
+                    loggerCtx,
+                );
+                break;
+            }
+            if (payment.description !== orderCode) {
+                // Not for this order, skipping this payment
+                continue;
+            }
+            if (payment.status !== PaymentStatus.paid && payment.status !== PaymentStatus.authorized) {
+                // Not paid or authorized, skipping this payment
+                continue;
+            }
+            // This will handle the Mollie payment as if it were an incoming webhook
+            const updatedOrder = await this.handleMolliePaymentStatus(ctx, {
+                paymentMethodId: paymentMethod.id,
+                paymentId: payment.id,
+            });
+            if (updatedOrder) {
+                order = updatedOrder;
+            }
+            processedPaymentIds.push(payment.id);
+            if (order.state === 'PaymentSettled') {
+                break; // No further processing needed, because the order is already settled
+            }
+        }
+        if (processedPaymentIds.length > 0) {
+            Logger.info(
+                `Synced status for order '${order.code}' from '${originalOrderState}' to '${
+                    order.state
+                }' based on Mollie payment(s) ${processedPaymentIds.join(',')}`,
+                loggerCtx,
+            );
+        }
+
+        if (!(await this.configService.orderOptions.orderByCodeAccessStrategy.canAccessOrder(ctx, order))) {
+            throw new ForbiddenError(LogLevel.Verbose);
+        }
+        return order;
+    }
+
+    /**
+     * Get the Mollie client for the current channel
+     */
+    private async getMollieClient(
+        ctx: RequestContext,
+        paymentMethodCode?: string,
+    ): Promise<[MollieClient, PaymentMethod]> {
+        const paymentMethod = await this.getPaymentMethod(ctx, paymentMethodCode);
+        if (!paymentMethod) {
+            throw Error(`No Mollie payment method found`);
+        }
+        const apiKey = paymentMethod?.handler.args.find(arg => arg.name === 'apiKey')?.value;
+        if (!apiKey) {
+            throw Error(`No apiKey configured for payment method ${paymentMethod.code}`);
+        }
+        return [createMollieClient({ apiKey }), paymentMethod];
+    }
+
     /**
      * Dry run a transition to a given state.
      * As long as we don't call 'finalize', the transition never completes.
@@ -449,6 +532,9 @@ export class MollieService {
         await orderStateMachine.transition(ctx, orderCopy, state);
     }
 
+    /**
+     * Get the Mollie payment method by code, or the first payment method with Mollie as handler
+     */
     private async getPaymentMethod(
         ctx: RequestContext,
         paymentMethodCode?: string | null,