Browse Source

feat(payments-plugin): Prevent duplicate Mollie payments (#2691)

BREAKING CHANGE: MolliePlugin - A new mollieOrderId has been added in order to prevent duplicate payments in Mollie. This will require a DB migration to add the custom field to your DB schema.
Martijn 1 year ago
parent
commit
34b61cd0a7

+ 1 - 0
packages/core/src/service/index.ts

@@ -13,6 +13,7 @@ export * from './helpers/order-merger/order-merger';
 export * from './helpers/order-modifier/order-modifier';
 export * from './helpers/order-modifier/order-modifier';
 export * from './helpers/order-splitter/order-splitter';
 export * from './helpers/order-splitter/order-splitter';
 export * from './helpers/order-state-machine/order-state';
 export * from './helpers/order-state-machine/order-state';
+export * from './helpers/order-state-machine/order-state-machine';
 export * from './helpers/password-cipher/password-cipher';
 export * from './helpers/password-cipher/password-cipher';
 export * from './helpers/payment-state-machine/payment-state';
 export * from './helpers/payment-state-machine/payment-state';
 export * from './helpers/product-price-applicator/product-price-applicator';
 export * from './helpers/product-price-applicator/product-price-applicator';

+ 19 - 0
packages/payments-plugin/e2e/graphql/shop-queries.ts

@@ -185,6 +185,25 @@ export const ADD_ITEM_TO_ORDER = gql`
     ${TEST_ORDER_FRAGMENT}
     ${TEST_ORDER_FRAGMENT}
 `;
 `;
 
 
+export const ADJUST_ORDER_LINE = gql`
+    mutation AdjustOrderLine($orderLineId: ID!, $quantity: Int!) {
+        adjustOrderLine(orderLineId: $orderLineId, quantity: $quantity) {
+            ...TestOrderFragment
+            ... on ErrorResult {
+                errorCode
+                message
+            }
+            ... on InsufficientStockError {
+                quantityAvailable
+                order {
+                    ...TestOrderFragment
+                }
+            }
+        }
+    }
+    ${TEST_ORDER_FRAGMENT}
+`;
+
 export const GET_ORDER_BY_CODE = gql`
 export const GET_ORDER_BY_CODE = gql`
     query GetOrderByCode($code: String!) {
     query GetOrderByCode($code: String!) {
         orderByCode(code: $code) {
         orderByCode(code: $code) {

+ 27 - 10
packages/payments-plugin/e2e/mollie-dev-server.ts

@@ -26,11 +26,12 @@ import {
     LanguageCode,
     LanguageCode,
 } from './graphql/generated-admin-types';
 } from './graphql/generated-admin-types';
 import { AddItemToOrderMutation, AddItemToOrderMutationVariables } from './graphql/generated-shop-types';
 import { AddItemToOrderMutation, AddItemToOrderMutationVariables } from './graphql/generated-shop-types';
-import { ADD_ITEM_TO_ORDER } from './graphql/shop-queries';
+import { ADD_ITEM_TO_ORDER, ADJUST_ORDER_LINE } from './graphql/shop-queries';
 import { CREATE_MOLLIE_PAYMENT_INTENT, setShipping } from './payment-helpers';
 import { CREATE_MOLLIE_PAYMENT_INTENT, setShipping } from './payment-helpers';
 
 
 /**
 /**
  * This should only be used to locally test the Mollie payment plugin
  * This should only be used to locally test the Mollie payment plugin
+ * Make sure you have `MOLLIE_APIKEY=test_xxxx` in your .env file
  */
  */
 /* eslint-disable @typescript-eslint/no-floating-promises */
 /* eslint-disable @typescript-eslint/no-floating-promises */
 async function runMollieDevServer(useDynamicRedirectUrl: boolean) {
 async function runMollieDevServer(useDynamicRedirectUrl: boolean) {
@@ -101,21 +102,19 @@ async function runMollieDevServer(useDynamicRedirectUrl: boolean) {
             },
             },
         },
         },
     );
     );
-    // Prepare order for payment
+    // Prepare order with 2 items
     await shopClient.asUserWithCredentials('hayden.zieme12@hotmail.com', 'test');
     await shopClient.asUserWithCredentials('hayden.zieme12@hotmail.com', 'test');
+    // Add another item to the order
     await shopClient.query<AddItemToOrderMutation, AddItemToOrderMutationVariables>(ADD_ITEM_TO_ORDER, {
     await shopClient.query<AddItemToOrderMutation, AddItemToOrderMutationVariables>(ADD_ITEM_TO_ORDER, {
-        productVariantId: 'T_5',
+        productVariantId: 'T_4',
         quantity: 1,
         quantity: 1,
     });
     });
-    const ctx = new RequestContext({
-        apiType: 'admin',
-        isAuthorized: true,
-        authorizedAsOwnerOnly: false,
-        channel: await server.app.get(ChannelService).getDefaultChannel(),
+    await shopClient.query<AddItemToOrderMutation, AddItemToOrderMutationVariables>(ADD_ITEM_TO_ORDER, {
+        productVariantId: 'T_5',
+        quantity: 1,
     });
     });
     await setShipping(shopClient);
     await setShipping(shopClient);
-    // Add pre payment to order
-    const order = await server.app.get(OrderService).findOne(ctx, 1);
+    // Create payment intent
     const { createMolliePaymentIntent } = await shopClient.query(CREATE_MOLLIE_PAYMENT_INTENT, {
     const { createMolliePaymentIntent } = await shopClient.query(CREATE_MOLLIE_PAYMENT_INTENT, {
         input: {
         input: {
             redirectUrl: `${tunnel.url}/admin/orders?filter=open&page=1&dynamicRedirectUrl=true`,
             redirectUrl: `${tunnel.url}/admin/orders?filter=open&page=1&dynamicRedirectUrl=true`,
@@ -128,6 +127,24 @@ async function runMollieDevServer(useDynamicRedirectUrl: boolean) {
     }
     }
     // eslint-disable-next-line no-console
     // eslint-disable-next-line no-console
     console.log('\x1b[41m', `Mollie payment link: ${createMolliePaymentIntent.url as string}`, '\x1b[0m');
     console.log('\x1b[41m', `Mollie payment link: ${createMolliePaymentIntent.url as string}`, '\x1b[0m');
+
+    // Remove first orderLine
+    await shopClient.query(ADJUST_ORDER_LINE, {
+        orderLineId: 'T_1',
+        quantity: 0,
+    });
+    await setShipping(shopClient);
+
+    // Create another intent after Xs, should update the mollie order
+    await new Promise(resolve => setTimeout(resolve, 5000));
+    const { createMolliePaymentIntent: secondIntent } = await shopClient.query(CREATE_MOLLIE_PAYMENT_INTENT, {
+        input: {
+            redirectUrl: `${tunnel.url}/admin/orders?filter=open&page=1&dynamicRedirectUrl=true`,
+            paymentMethodCode: 'mollie',
+        },
+    });
+    // eslint-disable-next-line no-console
+    console.log('\x1b[41m', `Second payment link: ${secondIntent.url as string}`, '\x1b[0m');
 }
 }
 
 
 (async () => {
 (async () => {

+ 86 - 26
packages/payments-plugin/e2e/mollie-payment.e2e-spec.ts

@@ -1,5 +1,13 @@
 import { OrderStatus } from '@mollie/api-client';
 import { OrderStatus } from '@mollie/api-client';
-import { ChannelService, LanguageCode, mergeConfig, OrderService, RequestContext } from '@vendure/core';
+import {
+    ChannelService,
+    EventBus,
+    LanguageCode,
+    mergeConfig,
+    OrderPlacedEvent,
+    OrderService,
+    RequestContext,
+} from '@vendure/core';
 import {
 import {
     SettlePaymentMutation,
     SettlePaymentMutation,
     SettlePaymentMutationVariables,
     SettlePaymentMutationVariables,
@@ -69,6 +77,9 @@ const mockData = {
             ],
             ],
         },
         },
         resource: 'order',
         resource: 'order',
+        metadata: {
+            languageCode: 'nl',
+        },
         mode: 'test',
         mode: 'test',
         method: 'test-method',
         method: 'test-method',
         profileId: '123',
         profileId: '123',
@@ -128,7 +139,7 @@ let order: TestOrderFragmentFragment;
 let serverPort: number;
 let serverPort: number;
 const SURCHARGE_AMOUNT = -20000;
 const SURCHARGE_AMOUNT = -20000;
 
 
-describe('Mollie payments (with useDynamicRedirectUrl set to true)', () => {
+describe('Mollie payments with useDynamicRedirectUrl=false', () => {
     beforeAll(async () => {
     beforeAll(async () => {
         const devConfig = mergeConfig(testConfig(), {
         const devConfig = mergeConfig(testConfig(), {
             plugins: [MolliePlugin.init({ vendureHost: mockData.host })],
             plugins: [MolliePlugin.init({ vendureHost: mockData.host })],
@@ -266,7 +277,7 @@ describe('Mollie payments (with useDynamicRedirectUrl set to true)', () => {
                     },
                     },
                 },
                 },
             );
             );
-            expect(result.message).toContain('The following variants are out of stock');
+            expect(result.message).toContain('insufficient stock of Pinelab stickers');
             // Set stock back to not tracking
             // Set stock back to not tracking
             ({ updateProductVariants } = await adminClient.query(UPDATE_PRODUCT_VARIANTS, {
             ({ updateProductVariants } = await adminClient.query(UPDATE_PRODUCT_VARIANTS, {
                 input: {
                 input: {
@@ -324,6 +335,42 @@ describe('Mollie payments (with useDynamicRedirectUrl set to true)', () => {
             });
             });
         });
         });
 
 
+        it('Should update existing Mollie order', 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,
+                },
+            });
+            // We expect the patch request to add 3 order lines, because the mock response has 0 lines
+            expect(createMolliePaymentIntent.url).toBeDefined();
+            expect(molliePatchRequest.operations).toBeDefined();
+            expect(molliePatchRequest.operations[0].operation).toBe('add');
+            expect(molliePatchRequest.operations[0].data).toHaveProperty('name');
+            expect(molliePatchRequest.operations[0].data).toHaveProperty('quantity');
+            expect(molliePatchRequest.operations[0].data).toHaveProperty('unitPrice');
+            expect(molliePatchRequest.operations[0].data).toHaveProperty('totalAmount');
+            expect(molliePatchRequest.operations[0].data).toHaveProperty('vatRate');
+            expect(molliePatchRequest.operations[0].data).toHaveProperty('vatAmount');
+            expect(molliePatchRequest.operations[1].operation).toBe('add');
+            expect(molliePatchRequest.operations[2].operation).toBe('add');
+        });
+
         it('Should get payment url with deducted amount if a payment is already made', async () => {
         it('Should get payment url with deducted amount if a payment is already made', async () => {
             let mollieRequest: any | undefined;
             let mollieRequest: any | undefined;
             nock('https://api.mollie.com/')
             nock('https://api.mollie.com/')
@@ -385,7 +432,15 @@ describe('Mollie payments (with useDynamicRedirectUrl set to true)', () => {
             expect(adminOrder.state).toBe('ArrangingPayment');
             expect(adminOrder.state).toBe('ArrangingPayment');
         });
         });
 
 
+        let orderPlacedEvent: OrderPlacedEvent | undefined;
+
         it('Should place order after paying outstanding amount', async () => {
         it('Should place order after paying outstanding amount', async () => {
+            server.app
+                .get(EventBus)
+                .ofType(OrderPlacedEvent)
+                .subscribe(event => {
+                    orderPlacedEvent = event;
+                });
             nock('https://api.mollie.com/')
             nock('https://api.mollie.com/')
                 .get('/v2/orders/ord_mockId')
                 .get('/v2/orders/ord_mockId')
                 .reply(200, {
                 .reply(200, {
@@ -400,7 +455,7 @@ describe('Mollie payments (with useDynamicRedirectUrl set to true)', () => {
                 body: JSON.stringify({ id: mockData.mollieOrderResponse.id }),
                 body: JSON.stringify({ id: mockData.mollieOrderResponse.id }),
                 headers: { 'Content-Type': 'application/json' },
                 headers: { 'Content-Type': 'application/json' },
             });
             });
-            const { orderByCode } = await shopClient.query<GetOrderByCode.Query, GetOrderByCode.Variables>(
+            const { orderByCode } = await shopClient.query<GetOrderByCodeQuery, GetOrderByCodeQueryVariables>(
                 GET_ORDER_BY_CODE,
                 GET_ORDER_BY_CODE,
                 {
                 {
                     code: order.code,
                     code: order.code,
@@ -411,6 +466,11 @@ describe('Mollie payments (with useDynamicRedirectUrl set to true)', () => {
             expect(order.state).toBe('PaymentSettled');
             expect(order.state).toBe('PaymentSettled');
         });
         });
 
 
+        it('Should have preserved original languageCode ', async () => {
+            // We've set the languageCode to 'nl' in the mock response's metadata
+            expect(orderPlacedEvent?.ctx.languageCode).toBe('nl');
+        });
+
         it('Should have Mollie metadata on payment', async () => {
         it('Should have Mollie metadata on payment', async () => {
             const {
             const {
                 order: { payments },
                 order: { payments },
@@ -435,14 +495,14 @@ describe('Mollie payments (with useDynamicRedirectUrl set to true)', () => {
                 order.lines[0].id,
                 order.lines[0].id,
                 1,
                 1,
                 // eslint-disable-next-line @typescript-eslint/no-non-null-assertion
                 // eslint-disable-next-line @typescript-eslint/no-non-null-assertion
-                order!.payments[1].id,
+                order!.payments![1].id,
                 SURCHARGE_AMOUNT,
                 SURCHARGE_AMOUNT,
             );
             );
             expect(refund.state).toBe('Failed');
             expect(refund.state).toBe('Failed');
         });
         });
 
 
         it('Should successfully refund the Mollie payment', async () => {
         it('Should successfully refund the Mollie payment', async () => {
-            let mollieRequest;
+            let mollieRequest: any;
             nock('https://api.mollie.com/')
             nock('https://api.mollie.com/')
                 .get('/v2/orders/ord_mockId?embed=payments')
                 .get('/v2/orders/ord_mockId?embed=payments')
                 .reply(200, mockData.mollieOrderResponse);
                 .reply(200, mockData.mollieOrderResponse);
@@ -547,8 +607,8 @@ describe('Mollie payments (with useDynamicRedirectUrl set to true)', () => {
 
 
     it('Should add an unusable Mollie paymentMethod (missing redirectUrl)', async () => {
     it('Should add an unusable Mollie paymentMethod (missing redirectUrl)', async () => {
         const { createPaymentMethod } = await adminClient.query<
         const { createPaymentMethod } = await adminClient.query<
-            CreatePaymentMethod.Mutation,
-            CreatePaymentMethod.Variables
+            CreatePaymentMethodMutation,
+            CreatePaymentMethodMutationVariables
         >(CREATE_PAYMENT_METHOD, {
         >(CREATE_PAYMENT_METHOD, {
             input: {
             input: {
                 code: mockData.methodCodeBroken,
                 code: mockData.methodCodeBroken,
@@ -575,13 +635,13 @@ describe('Mollie payments (with useDynamicRedirectUrl set to true)', () => {
 
 
     it('Should prepare an order', async () => {
     it('Should prepare an order', async () => {
         await shopClient.asUserWithCredentials(customers[0].emailAddress, 'test');
         await shopClient.asUserWithCredentials(customers[0].emailAddress, 'test');
-        const { addItemToOrder } = await shopClient.query<AddItemToOrder.Mutation, AddItemToOrder.Variables>(
-            ADD_ITEM_TO_ORDER,
-            {
-                productVariantId: 'T_5',
-                quantity: 10,
-            },
-        );
+        const { addItemToOrder } = await shopClient.query<
+            AddItemToOrderMutation,
+            AddItemToOrderMutationVariables
+        >(ADD_ITEM_TO_ORDER, {
+            productVariantId: 'T_5',
+            quantity: 10,
+        });
         order = addItemToOrder as TestOrderFragmentFragment;
         order = addItemToOrder as TestOrderFragmentFragment;
         // Add surcharge
         // Add surcharge
         const ctx = new RequestContext({
         const ctx = new RequestContext({
@@ -613,7 +673,7 @@ describe('Mollie payments (with useDynamicRedirectUrl set to true)', () => {
     });
     });
 });
 });
 
 
-describe('Mollie payments (with useDynamicRedirectUrl set to true)', () => {
+describe('Mollie payments with useDynamicRedirectUrl=true', () => {
     beforeAll(async () => {
     beforeAll(async () => {
         const devConfig = mergeConfig(testConfig(), {
         const devConfig = mergeConfig(testConfig(), {
             plugins: [MolliePlugin.init({ vendureHost: mockData.host, useDynamicRedirectUrl: true })],
             plugins: [MolliePlugin.init({ vendureHost: mockData.host, useDynamicRedirectUrl: true })],
@@ -632,7 +692,7 @@ describe('Mollie payments (with useDynamicRedirectUrl set to true)', () => {
         await adminClient.asSuperAdmin();
         await adminClient.asSuperAdmin();
         ({
         ({
             customers: { items: customers },
             customers: { items: customers },
-        } = await adminClient.query<GetCustomerList.Query, GetCustomerList.Variables>(GET_CUSTOMER_LIST, {
+        } = await adminClient.query<GetCustomerListQuery, GetCustomerListQueryVariables>(GET_CUSTOMER_LIST, {
             options: {
             options: {
                 take: 2,
                 take: 2,
             },
             },
@@ -654,13 +714,13 @@ describe('Mollie payments (with useDynamicRedirectUrl set to true)', () => {
 
 
     it('Should prepare an order', async () => {
     it('Should prepare an order', async () => {
         await shopClient.asUserWithCredentials(customers[0].emailAddress, 'test');
         await shopClient.asUserWithCredentials(customers[0].emailAddress, 'test');
-        const { addItemToOrder } = await shopClient.query<AddItemToOrder.Mutation, AddItemToOrder.Variables>(
-            ADD_ITEM_TO_ORDER,
-            {
-                productVariantId: 'T_5',
-                quantity: 10,
-            },
-        );
+        const { addItemToOrder } = await shopClient.query<
+            AddItemToOrderMutation,
+            AddItemToOrderMutationVariables
+        >(ADD_ITEM_TO_ORDER, {
+            productVariantId: 'T_5',
+            quantity: 10,
+        });
         order = addItemToOrder as TestOrderFragmentFragment;
         order = addItemToOrder as TestOrderFragmentFragment;
         // Add surcharge
         // Add surcharge
         const ctx = new RequestContext({
         const ctx = new RequestContext({
@@ -678,8 +738,8 @@ describe('Mollie payments (with useDynamicRedirectUrl set to true)', () => {
 
 
     it('Should add a working Mollie paymentMethod without specifying redirectUrl', async () => {
     it('Should add a working Mollie paymentMethod without specifying redirectUrl', async () => {
         const { createPaymentMethod } = await adminClient.query<
         const { createPaymentMethod } = await adminClient.query<
-            CreatePaymentMethod.Mutation,
-            CreatePaymentMethod.Variables
+            CreatePaymentMethodMutation,
+            CreatePaymentMethodMutationVariables
         >(CREATE_PAYMENT_METHOD, {
         >(CREATE_PAYMENT_METHOD, {
             input: {
             input: {
                 code: mockData.methodCode,
                 code: mockData.methodCode,

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

@@ -0,0 +1,16 @@
+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,
+    },
+];

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

@@ -0,0 +1,76 @@
+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>
+}

+ 25 - 12
packages/payments-plugin/src/mollie/mollie.helpers.ts

@@ -1,7 +1,7 @@
 import { CreateParameters } from '@mollie/api-client/dist/types/src/binders/orders/parameters';
 import { CreateParameters } from '@mollie/api-client/dist/types/src/binders/orders/parameters';
 import { Amount } from '@mollie/api-client/dist/types/src/data/global';
 import { Amount } from '@mollie/api-client/dist/types/src/data/global';
 import { OrderAddress as MollieOrderAddress } from '@mollie/api-client/dist/types/src/data/orders/data';
 import { OrderAddress as MollieOrderAddress } from '@mollie/api-client/dist/types/src/data/orders/data';
-import { Customer, Order } from '@vendure/core';
+import { CurrencyCode, Customer, Order } from '@vendure/core';
 import currency from 'currency.js';
 import currency from 'currency.js';
 
 
 import { OrderAddress } from './graphql/generated-shop-types';
 import { OrderAddress } from './graphql/generated-shop-types';
@@ -34,7 +34,7 @@ export function toMollieOrderLines(order: Order, alreadyPaid: number): CreatePar
         quantity: line.quantity,
         quantity: line.quantity,
         unitPrice: toAmount(line.proratedLinePriceWithTax / line.quantity, order.currencyCode), // totalAmount has to match unitPrice * quantity
         unitPrice: toAmount(line.proratedLinePriceWithTax / line.quantity, order.currencyCode), // totalAmount has to match unitPrice * quantity
         totalAmount: toAmount(line.proratedLinePriceWithTax, order.currencyCode),
         totalAmount: toAmount(line.proratedLinePriceWithTax, order.currencyCode),
-        vatRate: String(line.taxRate),
+        vatRate: line.taxRate.toFixed(2),
         vatAmount: toAmount(
         vatAmount: toAmount(
             calculateLineTaxAmount(line.taxRate, line.proratedLinePriceWithTax),
             calculateLineTaxAmount(line.taxRate, line.proratedLinePriceWithTax),
             order.currencyCode,
             order.currencyCode,
@@ -52,14 +52,16 @@ export function toMollieOrderLines(order: Order, alreadyPaid: number): CreatePar
         })),
         })),
     );
     );
     // Add surcharges
     // Add surcharges
-    lines.push(...order.surcharges.map(surcharge => ({
-        name: surcharge.description,
-        quantity: 1,
-        unitPrice: toAmount(surcharge.priceWithTax, order.currencyCode),
-        totalAmount: toAmount(surcharge.priceWithTax, order.currencyCode),
-        vatRate: String(surcharge.taxRate),
-        vatAmount: toAmount(surcharge.priceWithTax - surcharge.price, order.currencyCode),
-    })));
+    lines.push(
+        ...order.surcharges.map(surcharge => ({
+            name: surcharge.description,
+            quantity: 1,
+            unitPrice: toAmount(surcharge.priceWithTax, order.currencyCode),
+            totalAmount: toAmount(surcharge.priceWithTax, order.currencyCode),
+            vatRate: String(surcharge.taxRate),
+            vatAmount: toAmount(surcharge.priceWithTax - surcharge.price, order.currencyCode),
+        })),
+    );
     // Deduct amount already paid
     // Deduct amount already paid
     if (alreadyPaid) {
     if (alreadyPaid) {
         lines.push({
         lines.push({
@@ -85,7 +87,7 @@ export function toAmount(value: number, orderCurrency: string): Amount {
 }
 }
 
 
 /**
 /**
- * Return to number of cents
+ * Return to number of cents. E.g. '10.00' => 1000
  */
  */
 export function amountToCents(amount: Amount): number {
 export function amountToCents(amount: Amount): number {
     return currency(amount.value).intValue;
     return currency(amount.value).intValue;
@@ -99,7 +101,6 @@ export function amountToCents(amount: Amount): number {
 export function calculateLineTaxAmount(taxRate: number, orderLinePriceWithTax: number): number {
 export function calculateLineTaxAmount(taxRate: number, orderLinePriceWithTax: number): number {
     const taxMultiplier = taxRate / 100;
     const taxMultiplier = taxRate / 100;
     return orderLinePriceWithTax * (taxMultiplier / (1 + taxMultiplier)); // I.E. €99,99 * (0,2 ÷ 1,2) with a 20% taxrate
     return orderLinePriceWithTax * (taxMultiplier / (1 + taxMultiplier)); // I.E. €99,99 * (0,2 ÷ 1,2) with a 20% taxrate
-
 }
 }
 
 
 /**
 /**
@@ -148,3 +149,15 @@ export function getLocale(countryCode: string, channelLanguage: string): string
     // If no order locale and no channel locale, return a default, otherwise order creation will fail
     // If no order locale and no channel locale, return a default, otherwise order creation will fail
     return allowedLocales[0];
     return allowedLocales[0];
 }
 }
+
+export function areOrderLinesEqual(line1: CreateParameters['lines'][0], line2: CreateParameters['lines'][0]): boolean {
+    return (
+        line1.name === line2.name &&
+        line1.quantity === line2.quantity &&
+        line1.unitPrice.value === line2.unitPrice.value &&
+        line1.unitPrice.currency === line2.unitPrice.currency &&
+        line1.totalAmount.value === line2.totalAmount.value &&
+        line1.vatRate === line2.vatRate &&
+        line1.vatAmount.value === line2.vatAmount.value
+    );
+}

+ 13 - 3
packages/payments-plugin/src/mollie/mollie.plugin.ts

@@ -9,6 +9,7 @@ import {
 } from '@vendure/core';
 } from '@vendure/core';
 
 
 import { PLUGIN_INIT_OPTIONS } from './constants';
 import { PLUGIN_INIT_OPTIONS } from './constants';
+import { orderCustomFields } from './custom-fields';
 import { shopSchema } from './mollie-shop-schema';
 import { shopSchema } from './mollie-shop-schema';
 import { MollieController } from './mollie.controller';
 import { MollieController } from './mollie.controller';
 import { molliePaymentHandler } from './mollie.handler';
 import { molliePaymentHandler } from './mollie.handler';
@@ -115,8 +116,9 @@ export interface MolliePluginOptions {
  *       MolliePlugin.init({ vendureHost: 'https://yourhost.io/', useDynamicRedirectUrl: true }),
  *       MolliePlugin.init({ vendureHost: 'https://yourhost.io/', useDynamicRedirectUrl: true }),
  *     ]
  *     ]
  *     ```
  *     ```
- * 2. Create a new PaymentMethod in the Admin UI, and select "Mollie payments" as the handler.
- * 3. Set your Mollie apiKey in the `API Key` field.
+ * 2. Run a database migration to add the `mollieOrderId` custom field to the order entity.
+ * 3. Create a new PaymentMethod in the Admin UI, and select "Mollie payments" as the handler.
+ * 4. Set your Mollie apiKey in the `API Key` field.
  *
  *
  * ## Specifying the redirectUrl
  * ## Specifying the redirectUrl
  *
  *
@@ -128,7 +130,6 @@ export interface MolliePluginOptions {
  * By default, this option is set to `false` for backwards compatibility. In a future version, this option will be deprecated.
  * By default, this option is set to `false` for backwards compatibility. In a future version, this option will be deprecated.
  * Upon deprecation, the `redirectUrl` will always be passed as an argument to the `createPaymentIntent` mutation.
  * Upon deprecation, the `redirectUrl` will always be passed as an argument to the `createPaymentIntent` mutation.
  *
  *
- * TODO toevoegen van /code weggehaald..!
  * ## Storefront usage
  * ## Storefront usage
  *
  *
  * In your storefront you add a payment to an order using the `createMolliePaymentIntent` mutation. In this example, our Mollie
  * In your storefront you add a payment to an order using the `createMolliePaymentIntent` mutation. In this example, our Mollie
@@ -196,6 +197,14 @@ export interface MolliePluginOptions {
  * If you don't want this behaviour (Authorized first), you can set 'autoCapture=true' on the payment method. This option will immediately
  * If you don't want this behaviour (Authorized first), you can set 'autoCapture=true' on the payment method. This option will immediately
  * capture the payment after a customer authorizes the payment.
  * capture the payment after a customer authorizes the payment.
  *
  *
+ * ## ArrangingAdditionalPayment state
+ *
+ * In some rare cases, a customer can add items to the active order, while a Mollie payment 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.
+ *
  * @docsCategory core plugins/PaymentsPlugin
  * @docsCategory core plugins/PaymentsPlugin
  * @docsPage MolliePlugin
  * @docsPage MolliePlugin
  * @docsWeight 0
  * @docsWeight 0
@@ -206,6 +215,7 @@ export interface MolliePluginOptions {
     providers: [MollieService, { provide: PLUGIN_INIT_OPTIONS, useFactory: () => MolliePlugin.options }],
     providers: [MollieService, { provide: PLUGIN_INIT_OPTIONS, useFactory: () => MolliePlugin.options }],
     configuration: (config: RuntimeVendureConfig) => {
     configuration: (config: RuntimeVendureConfig) => {
         config.paymentOptions.paymentMethodHandlers.push(molliePaymentHandler);
         config.paymentOptions.paymentMethodHandlers.push(molliePaymentHandler);
+        config.customFields.Order.push(...orderCustomFields);
         return config;
         return config;
     },
     },
     shopApiExtensions: {
     shopApiExtensions: {

+ 164 - 39
packages/payments-plugin/src/mollie/mollie.service.ts

@@ -1,17 +1,19 @@
-import createMollieClient, {
+import {
     Order as MollieOrder,
     Order as MollieOrder,
     OrderStatus,
     OrderStatus,
     PaymentMethod as MollieClientMethod,
     PaymentMethod as MollieClientMethod,
-    Locale,
 } from '@mollie/api-client';
 } from '@mollie/api-client';
 import { CreateParameters } from '@mollie/api-client/dist/types/src/binders/orders/parameters';
 import { CreateParameters } from '@mollie/api-client/dist/types/src/binders/orders/parameters';
 import { Inject, Injectable } from '@nestjs/common';
 import { Inject, Injectable } from '@nestjs/common';
 import { ModuleRef } from '@nestjs/core';
 import { ModuleRef } from '@nestjs/core';
 import {
 import {
     ActiveOrderService,
     ActiveOrderService,
+    assertFound,
     EntityHydrator,
     EntityHydrator,
     ErrorResult,
     ErrorResult,
+    ID,
     Injector,
     Injector,
+    LanguageCode,
     Logger,
     Logger,
     Order,
     Order,
     OrderService,
     OrderService,
@@ -23,19 +25,28 @@ import {
     ProductVariantService,
     ProductVariantService,
     RequestContext,
     RequestContext,
 } from '@vendure/core';
 } from '@vendure/core';
+import { OrderStateMachine } from '@vendure/core/';
 import { totalCoveredByPayments } from '@vendure/core/dist/service/helpers/utils/order-utils';
 import { totalCoveredByPayments } from '@vendure/core/dist/service/helpers/utils/order-utils';
 
 
 import { loggerCtx, PLUGIN_INIT_OPTIONS } from './constants';
 import { loggerCtx, PLUGIN_INIT_OPTIONS } from './constants';
+import { OrderWithMollieReference } from './custom-fields';
 import {
 import {
     ErrorCode,
     ErrorCode,
     MolliePaymentIntentError,
     MolliePaymentIntentError,
     MolliePaymentIntentInput,
     MolliePaymentIntentInput,
     MolliePaymentIntentResult,
     MolliePaymentIntentResult,
     MolliePaymentMethod,
     MolliePaymentMethod,
-    MolliePaymentMethodsInput,
 } from './graphql/generated-shop-types';
 } from './graphql/generated-shop-types';
-import { amountToCents, getLocale, toAmount, toMollieAddress, toMollieOrderLines } from './mollie.helpers';
+import {
+    amountToCents,
+    areOrderLinesEqual,
+    getLocale,
+    toAmount,
+    toMollieAddress,
+    toMollieOrderLines,
+} from './mollie.helpers';
 import { MolliePluginOptions } from './mollie.plugin';
 import { MolliePluginOptions } from './mollie.plugin';
+import { createExtendedMollieClient, ExtendedMollieClient, ManageOrderLineInput } from './extended-mollie-client';
 
 
 interface OrderStatusInput {
 interface OrderStatusInput {
     paymentMethodId: string;
     paymentMethodId: string;
@@ -45,17 +56,19 @@ interface OrderStatusInput {
 class PaymentIntentError implements MolliePaymentIntentError {
 class PaymentIntentError implements MolliePaymentIntentError {
     errorCode = ErrorCode.ORDER_PAYMENT_STATE_ERROR;
     errorCode = ErrorCode.ORDER_PAYMENT_STATE_ERROR;
 
 
-    constructor(public message: string) {}
+    constructor(public message: string) { }
 }
 }
 
 
 class InvalidInputError implements MolliePaymentIntentError {
 class InvalidInputError implements MolliePaymentIntentError {
     errorCode = ErrorCode.INELIGIBLE_PAYMENT_METHOD_ERROR;
     errorCode = ErrorCode.INELIGIBLE_PAYMENT_METHOD_ERROR;
 
 
-    constructor(public message: string) {}
+    constructor(public message: string) { }
 }
 }
 
 
 @Injectable()
 @Injectable()
 export class MollieService {
 export class MollieService {
+    private readonly injector: Injector;
+
     constructor(
     constructor(
         private paymentMethodService: PaymentMethodService,
         private paymentMethodService: PaymentMethodService,
         @Inject(PLUGIN_INIT_OPTIONS) private options: MolliePluginOptions,
         @Inject(PLUGIN_INIT_OPTIONS) private options: MolliePluginOptions,
@@ -64,7 +77,9 @@ export class MollieService {
         private entityHydrator: EntityHydrator,
         private entityHydrator: EntityHydrator,
         private variantService: ProductVariantService,
         private variantService: ProductVariantService,
         private moduleRef: ModuleRef,
         private moduleRef: ModuleRef,
-    ) {}
+    ) {
+        this.injector = new Injector(this.moduleRef);
+    }
 
 
     /**
     /**
      * Creates a redirectUrl to Mollie for the given paymentMethod and current activeOrder
      * Creates a redirectUrl to Mollie for the given paymentMethod and current activeOrder
@@ -97,30 +112,27 @@ export class MollieService {
                 'payments',
                 'payments',
             ],
             ],
         });
         });
-        if (!order.lines?.length) {
-            return new PaymentIntentError('Cannot create payment intent for empty order');
-        }
-        if (!order.customer) {
-            return new PaymentIntentError('Cannot create payment intent for order without customer');
+        if (order.state !== 'ArrangingPayment' && order.state !== 'ArrangingAdditionalPayment') {
+            // Pre-check if order is transitionable to ArrangingPayment, because that will happen after Mollie payment
+            try {
+                await this.canTransitionTo(ctx, order.id, 'ArrangingPayment');
+            } catch (e) {
+                if ((e as Error).message) {
+                    return new PaymentIntentError((e as Error).message);
+                }
+                throw e;
+            }
         }
         }
-        if (!order.customer.firstName.length) {
+        if (!order.customer?.firstName.length) {
             return new PaymentIntentError(
             return new PaymentIntentError(
                 'Cannot create payment intent for order with customer that has no firstName set',
                 'Cannot create payment intent for order with customer that has no firstName set',
             );
             );
         }
         }
-        if (!order.customer.lastName.length) {
+        if (!order.customer?.lastName.length) {
             return new PaymentIntentError(
             return new PaymentIntentError(
                 'Cannot create payment intent for order with customer that has no lastName set',
                 'Cannot create payment intent for order with customer that has no lastName set',
             );
             );
         }
         }
-        if (!order.customer.emailAddress.length) {
-            return new PaymentIntentError(
-                'Cannot create payment intent for order with customer that has no emailAddress set',
-            );
-        }
-        if (!order.shippingLines?.length) {
-            return new PaymentIntentError('Cannot create payment intent for order without shippingMethod');
-        }
         if (!paymentMethod) {
         if (!paymentMethod) {
             return new PaymentIntentError(`No paymentMethod found with code ${paymentMethodCode}`);
             return new PaymentIntentError(`No paymentMethod found with code ${paymentMethodCode}`);
         }
         }
@@ -140,14 +152,6 @@ export class MollieService {
             }
             }
             redirectUrl = paymentMethodRedirectUrl;
             redirectUrl = paymentMethodRedirectUrl;
         }
         }
-        const variantsWithInsufficientSaleableStock = await this.getVariantsWithInsufficientStock(ctx, order);
-        if (variantsWithInsufficientSaleableStock.length) {
-            return new PaymentIntentError(
-                `The following variants are out of stock: ${variantsWithInsufficientSaleableStock
-                    .map(v => v.name)
-                    .join(', ')}`,
-            );
-        }
         const apiKey = paymentMethod.handler.args.find(arg => arg.name === 'apiKey')?.value;
         const apiKey = paymentMethod.handler.args.find(arg => arg.name === 'apiKey')?.value;
         if (!apiKey) {
         if (!apiKey) {
             Logger.warn(
             Logger.warn(
@@ -156,7 +160,7 @@ export class MollieService {
             );
             );
             return new PaymentIntentError(`Paymentmethod ${paymentMethod.code} has no apiKey configured`);
             return new PaymentIntentError(`Paymentmethod ${paymentMethod.code} has no apiKey configured`);
         }
         }
-        const mollieClient = createMollieClient({ apiKey });
+        const mollieClient = createExtendedMollieClient({ apiKey });
         redirectUrl =
         redirectUrl =
             redirectUrl.endsWith('/') && this.options.useDynamicRedirectUrl !== true
             redirectUrl.endsWith('/') && this.options.useDynamicRedirectUrl !== true
                 ? redirectUrl.slice(0, -1)
                 ? redirectUrl.slice(0, -1)
@@ -170,7 +174,7 @@ export class MollieService {
         if (!billingAddress) {
         if (!billingAddress) {
             return new InvalidInputError(
             return new InvalidInputError(
                 "Order doesn't have a complete shipping address or billing address. " +
                 "Order doesn't have a complete shipping address or billing address. " +
-                    'At least city, postalCode, streetline1 and country are needed to create a payment intent.',
+                'At least city, postalCode, streetline1 and country are needed to create a payment intent.',
             );
             );
         }
         }
         const alreadyPaid = totalCoveredByPayments(order);
         const alreadyPaid = totalCoveredByPayments(order);
@@ -184,11 +188,33 @@ export class MollieService {
             billingAddress,
             billingAddress,
             locale: getLocale(billingAddress.country, ctx.languageCode),
             locale: getLocale(billingAddress.country, ctx.languageCode),
             lines: toMollieOrderLines(order, alreadyPaid),
             lines: toMollieOrderLines(order, alreadyPaid),
+            metadata: {
+                languageCode: ctx.languageCode,
+            },
         };
         };
         if (molliePaymentMethodCode) {
         if (molliePaymentMethodCode) {
             orderInput.method = molliePaymentMethodCode as MollieClientMethod;
             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);
         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 => {
+            Logger.error(`Failed to save Mollie order ID: ${(e as Error).message}`, loggerCtx);
+        });
         Logger.info(`Created Mollie order ${mollieOrder.id} for order ${order.code}`, loggerCtx);
         Logger.info(`Created Mollie order ${mollieOrder.id} for order ${order.code}`, loggerCtx);
         const url = mollieOrder.getCheckoutUrl();
         const url = mollieOrder.getCheckoutUrl();
         if (!url) {
         if (!url) {
@@ -220,8 +246,18 @@ export class MollieService {
         if (!apiKey) {
         if (!apiKey) {
             throw Error(`No apiKey found for payment ${paymentMethod.id} for channel ${ctx.channel.token}`);
             throw Error(`No apiKey found for payment ${paymentMethod.id} for channel ${ctx.channel.token}`);
         }
         }
-        const client = createMollieClient({ apiKey });
+        const client = createExtendedMollieClient({ apiKey });
         const mollieOrder = await client.orders.get(orderId);
         const mollieOrder = await client.orders.get(orderId);
+        if (mollieOrder.metadata?.languageCode) {
+            // Recreate ctx with the original languageCode
+            ctx = new RequestContext({
+                apiType: 'admin',
+                isAuthorized: true,
+                authorizedAsOwnerOnly: false,
+                channel: ctx.channel,
+                languageCode: mollieOrder.metadata.languageCode as LanguageCode,
+            });
+        }
         Logger.info(
         Logger.info(
             `Processing status '${mollieOrder.status}' for order ${mollieOrder.orderNumber} for channel ${ctx.channel.token} for Mollie order ${orderId}`,
             `Processing status '${mollieOrder.status}' for order ${mollieOrder.orderNumber} for channel ${ctx.channel.token} for Mollie order ${orderId}`,
             loggerCtx,
             loggerCtx,
@@ -289,7 +325,7 @@ export class MollieService {
         paymentMethodCode: string,
         paymentMethodCode: string,
         status: 'Authorized' | 'Settled',
         status: 'Authorized' | 'Settled',
     ): Promise<Order> {
     ): Promise<Order> {
-        if (order.state !== 'ArrangingPayment') {
+        if (order.state !== 'ArrangingPayment' && order.state !== 'ArrangingAdditionalPayment') {
             const transitionToStateResult = await this.orderService.transitionToState(
             const transitionToStateResult = await this.orderService.transitionToState(
                 ctx,
                 ctx,
                 order.id,
                 order.id,
@@ -298,7 +334,7 @@ export class MollieService {
             if (transitionToStateResult instanceof OrderStateTransitionError) {
             if (transitionToStateResult instanceof OrderStateTransitionError) {
                 throw Error(
                 throw Error(
                     `Error transitioning order ${order.code} from ${transitionToStateResult.fromState} ` +
                     `Error transitioning order ${order.code} from ${transitionToStateResult.fromState} ` +
-                        `to ${transitionToStateResult.toState}: ${transitionToStateResult.message}`,
+                    `to ${transitionToStateResult.toState}: ${transitionToStateResult.message}`,
                 );
                 );
             }
             }
         }
         }
@@ -336,8 +372,7 @@ export class MollieService {
         const result = await this.orderService.settlePayment(ctx, payment.id);
         const result = await this.orderService.settlePayment(ctx, payment.id);
         if ((result as ErrorResult).message) {
         if ((result as ErrorResult).message) {
             throw Error(
             throw Error(
-                `Error settling payment ${payment.id} for order ${order.code}: ${
-                    (result as ErrorResult).errorCode
+                `Error settling payment ${payment.id} for order ${order.code}: ${(result as ErrorResult).errorCode
                 } - ${(result as ErrorResult).message}`,
                 } - ${(result as ErrorResult).message}`,
             );
             );
         }
         }
@@ -353,10 +388,10 @@ export class MollieService {
             throw Error(`No apiKey configured for payment method ${paymentMethodCode}`);
             throw Error(`No apiKey configured for payment method ${paymentMethodCode}`);
         }
         }
 
 
-        const client = createMollieClient({ apiKey });
+        const client = createExtendedMollieClient({ apiKey });
         const activeOrder = await this.activeOrderService.getActiveOrder(ctx, undefined);
         const activeOrder = await this.activeOrderService.getActiveOrder(ctx, undefined);
         const additionalParams = await this.options.enabledPaymentMethodsParams?.(
         const additionalParams = await this.options.enabledPaymentMethodsParams?.(
-            new Injector(this.moduleRef),
+            this.injector,
             ctx,
             ctx,
             activeOrder ?? null,
             activeOrder ?? null,
         );
         );
@@ -383,6 +418,96 @@ export class MollieService {
         return variantsWithInsufficientSaleableStock;
         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,
+        });
+    }
+
+    /**
+     * Compare existing order lines with the new input,
+     * and update, add or cancel the order lines accordingly.
+     *
+     * We compare and update order lines based on their index, because there is no unique identifier
+     */
+    private async updateMollieOrderLines(
+        mollieClient: ExtendedMollieClient,
+        existingMollieOrder: MollieOrder,
+        newMollieOrderLines: CreateParameters['lines']
+    ): Promise<MollieOrder> {
+        const manageOrderLinesInput: ManageOrderLineInput = {
+            operations: []
+        }
+        // Update or add new order lines
+        newMollieOrderLines.forEach((newLine, index) => {
+            const existingLine = existingMollieOrder.lines[index];
+            if (existingLine && !areOrderLinesEqual(existingLine, newLine)) {
+                // Update if exists but not equal
+                manageOrderLinesInput.operations.push({
+                    operation: 'update',
+                    data: {
+                        ...newLine,
+                        id: existingLine.id
+                    }
+                })
+            } else {
+                // Add new line if it doesn't exist
+                manageOrderLinesInput.operations.push({
+                    operation: 'add',
+                    data: newLine
+                })
+            }
+        });
+        // Cancel any order lines that are in the existing Mollie order, but not in the new input
+        existingMollieOrder.lines.forEach((existingLine, index) => {
+            const newLine = newMollieOrderLines[index];
+            if (!newLine) {
+                manageOrderLinesInput.operations.push({
+                    operation: 'cancel',
+                    data: { id: existingLine.id }
+                })
+            }
+        });
+        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.
+     */
+    private async canTransitionTo(ctx: RequestContext, orderId: ID, state: OrderState) {
+        // Fetch new order object, because `transition()` mutates the order object
+        const orderCopy = await assertFound(this.orderService.findOne(ctx, orderId));
+        const orderStateMachine = this.injector.get(OrderStateMachine);
+        await orderStateMachine.transition(ctx, orderCopy, state);
+    }
+
     private async getPaymentMethod(
     private async getPaymentMethod(
         ctx: RequestContext,
         ctx: RequestContext,
         paymentMethodCode: string,
         paymentMethodCode: string,