1
0
Эх сурвалжийг харах

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 жил өмнө
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-splitter/order-splitter';
 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/payment-state-machine/payment-state';
 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}
 `;
 
+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`
     query GetOrderByCode($code: String!) {
         orderByCode(code: $code) {

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

@@ -26,11 +26,12 @@ import {
     LanguageCode,
 } from './graphql/generated-admin-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';
 
 /**
  * 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 */
 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');
+    // Add another item to the order
     await shopClient.query<AddItemToOrderMutation, AddItemToOrderMutationVariables>(ADD_ITEM_TO_ORDER, {
-        productVariantId: 'T_5',
+        productVariantId: 'T_4',
         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);
-    // 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, {
         input: {
             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
     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 () => {

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

@@ -1,5 +1,13 @@
 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 {
     SettlePaymentMutation,
     SettlePaymentMutationVariables,
@@ -69,6 +77,9 @@ const mockData = {
             ],
         },
         resource: 'order',
+        metadata: {
+            languageCode: 'nl',
+        },
         mode: 'test',
         method: 'test-method',
         profileId: '123',
@@ -128,7 +139,7 @@ let order: TestOrderFragmentFragment;
 let serverPort: number;
 const SURCHARGE_AMOUNT = -20000;
 
-describe('Mollie payments (with useDynamicRedirectUrl set to true)', () => {
+describe('Mollie payments with useDynamicRedirectUrl=false', () => {
     beforeAll(async () => {
         const devConfig = mergeConfig(testConfig(), {
             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
             ({ updateProductVariants } = await adminClient.query(UPDATE_PRODUCT_VARIANTS, {
                 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 () => {
             let mollieRequest: any | undefined;
             nock('https://api.mollie.com/')
@@ -385,7 +432,15 @@ describe('Mollie payments (with useDynamicRedirectUrl set to true)', () => {
             expect(adminOrder.state).toBe('ArrangingPayment');
         });
 
+        let orderPlacedEvent: OrderPlacedEvent | undefined;
+
         it('Should place order after paying outstanding amount', async () => {
+            server.app
+                .get(EventBus)
+                .ofType(OrderPlacedEvent)
+                .subscribe(event => {
+                    orderPlacedEvent = event;
+                });
             nock('https://api.mollie.com/')
                 .get('/v2/orders/ord_mockId')
                 .reply(200, {
@@ -400,7 +455,7 @@ describe('Mollie payments (with useDynamicRedirectUrl set to true)', () => {
                 body: JSON.stringify({ id: mockData.mollieOrderResponse.id }),
                 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,
                 {
                     code: order.code,
@@ -411,6 +466,11 @@ describe('Mollie payments (with useDynamicRedirectUrl set to true)', () => {
             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 () => {
             const {
                 order: { payments },
@@ -435,14 +495,14 @@ describe('Mollie payments (with useDynamicRedirectUrl set to true)', () => {
                 order.lines[0].id,
                 1,
                 // eslint-disable-next-line @typescript-eslint/no-non-null-assertion
-                order!.payments[1].id,
+                order!.payments![1].id,
                 SURCHARGE_AMOUNT,
             );
             expect(refund.state).toBe('Failed');
         });
 
         it('Should successfully refund the Mollie payment', async () => {
-            let mollieRequest;
+            let mollieRequest: any;
             nock('https://api.mollie.com/')
                 .get('/v2/orders/ord_mockId?embed=payments')
                 .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 () => {
         const { createPaymentMethod } = await adminClient.query<
-            CreatePaymentMethod.Mutation,
-            CreatePaymentMethod.Variables
+            CreatePaymentMethodMutation,
+            CreatePaymentMethodMutationVariables
         >(CREATE_PAYMENT_METHOD, {
             input: {
                 code: mockData.methodCodeBroken,
@@ -575,13 +635,13 @@ describe('Mollie payments (with useDynamicRedirectUrl set to true)', () => {
 
     it('Should prepare an order', async () => {
         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;
         // Add surcharge
         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 () => {
         const devConfig = mergeConfig(testConfig(), {
             plugins: [MolliePlugin.init({ vendureHost: mockData.host, useDynamicRedirectUrl: true })],
@@ -632,7 +692,7 @@ describe('Mollie payments (with useDynamicRedirectUrl set to true)', () => {
         await adminClient.asSuperAdmin();
         ({
             customers: { items: customers },
-        } = await adminClient.query<GetCustomerList.Query, GetCustomerList.Variables>(GET_CUSTOMER_LIST, {
+        } = await adminClient.query<GetCustomerListQuery, GetCustomerListQueryVariables>(GET_CUSTOMER_LIST, {
             options: {
                 take: 2,
             },
@@ -654,13 +714,13 @@ describe('Mollie payments (with useDynamicRedirectUrl set to true)', () => {
 
     it('Should prepare an order', async () => {
         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;
         // Add surcharge
         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 () => {
         const { createPaymentMethod } = await adminClient.query<
-            CreatePaymentMethod.Mutation,
-            CreatePaymentMethod.Variables
+            CreatePaymentMethodMutation,
+            CreatePaymentMethodMutationVariables
         >(CREATE_PAYMENT_METHOD, {
             input: {
                 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 { 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 { Customer, Order } from '@vendure/core';
+import { CurrencyCode, Customer, Order } from '@vendure/core';
 import currency from 'currency.js';
 
 import { OrderAddress } from './graphql/generated-shop-types';
@@ -34,7 +34,7 @@ export function toMollieOrderLines(order: Order, alreadyPaid: number): CreatePar
         quantity: line.quantity,
         unitPrice: toAmount(line.proratedLinePriceWithTax / line.quantity, order.currencyCode), // totalAmount has to match unitPrice * quantity
         totalAmount: toAmount(line.proratedLinePriceWithTax, order.currencyCode),
-        vatRate: String(line.taxRate),
+        vatRate: line.taxRate.toFixed(2),
         vatAmount: toAmount(
             calculateLineTaxAmount(line.taxRate, line.proratedLinePriceWithTax),
             order.currencyCode,
@@ -52,14 +52,16 @@ export function toMollieOrderLines(order: Order, alreadyPaid: number): CreatePar
         })),
     );
     // 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
     if (alreadyPaid) {
         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 {
     return currency(amount.value).intValue;
@@ -99,7 +101,6 @@ export function amountToCents(amount: Amount): number {
 export function calculateLineTaxAmount(taxRate: number, orderLinePriceWithTax: number): number {
     const taxMultiplier = taxRate / 100;
     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
     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';
 
 import { PLUGIN_INIT_OPTIONS } from './constants';
+import { orderCustomFields } from './custom-fields';
 import { shopSchema } from './mollie-shop-schema';
 import { MollieController } from './mollie.controller';
 import { molliePaymentHandler } from './mollie.handler';
@@ -115,8 +116,9 @@ export interface MolliePluginOptions {
  *       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
  *
@@ -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.
  * Upon deprecation, the `redirectUrl` will always be passed as an argument to the `createPaymentIntent` mutation.
  *
- * TODO toevoegen van /code weggehaald..!
  * ## Storefront usage
  *
  * 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
  * 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
  * @docsPage MolliePlugin
  * @docsWeight 0
@@ -206,6 +215,7 @@ 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: {

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

@@ -1,17 +1,19 @@
-import createMollieClient, {
+import {
     Order as MollieOrder,
     OrderStatus,
     PaymentMethod as MollieClientMethod,
-    Locale,
 } 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';
 import {
     ActiveOrderService,
+    assertFound,
     EntityHydrator,
     ErrorResult,
+    ID,
     Injector,
+    LanguageCode,
     Logger,
     Order,
     OrderService,
@@ -23,19 +25,28 @@ import {
     ProductVariantService,
     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';
+import { OrderWithMollieReference } from './custom-fields';
 import {
     ErrorCode,
     MolliePaymentIntentError,
     MolliePaymentIntentInput,
     MolliePaymentIntentResult,
     MolliePaymentMethod,
-    MolliePaymentMethodsInput,
 } 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 { createExtendedMollieClient, ExtendedMollieClient, ManageOrderLineInput } from './extended-mollie-client';
 
 interface OrderStatusInput {
     paymentMethodId: string;
@@ -45,17 +56,19 @@ interface OrderStatusInput {
 class PaymentIntentError implements MolliePaymentIntentError {
     errorCode = ErrorCode.ORDER_PAYMENT_STATE_ERROR;
 
-    constructor(public message: string) {}
+    constructor(public message: string) { }
 }
 
 class InvalidInputError implements MolliePaymentIntentError {
     errorCode = ErrorCode.INELIGIBLE_PAYMENT_METHOD_ERROR;
 
-    constructor(public message: string) {}
+    constructor(public message: string) { }
 }
 
 @Injectable()
 export class MollieService {
+    private readonly injector: Injector;
+
     constructor(
         private paymentMethodService: PaymentMethodService,
         @Inject(PLUGIN_INIT_OPTIONS) private options: MolliePluginOptions,
@@ -64,7 +77,9 @@ export class MollieService {
         private entityHydrator: EntityHydrator,
         private variantService: ProductVariantService,
         private moduleRef: ModuleRef,
-    ) {}
+    ) {
+        this.injector = new Injector(this.moduleRef);
+    }
 
     /**
      * Creates a redirectUrl to Mollie for the given paymentMethod and current activeOrder
@@ -97,30 +112,27 @@ export class MollieService {
                 '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(
                 '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(
                 '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) {
             return new PaymentIntentError(`No paymentMethod found with code ${paymentMethodCode}`);
         }
@@ -140,14 +152,6 @@ export class MollieService {
             }
             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;
         if (!apiKey) {
             Logger.warn(
@@ -156,7 +160,7 @@ export class MollieService {
             );
             return new PaymentIntentError(`Paymentmethod ${paymentMethod.code} has no apiKey configured`);
         }
-        const mollieClient = createMollieClient({ apiKey });
+        const mollieClient = createExtendedMollieClient({ apiKey });
         redirectUrl =
             redirectUrl.endsWith('/') && this.options.useDynamicRedirectUrl !== true
                 ? redirectUrl.slice(0, -1)
@@ -170,7 +174,7 @@ export class MollieService {
         if (!billingAddress) {
             return new InvalidInputError(
                 "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);
@@ -184,11 +188,33 @@ export class MollieService {
             billingAddress,
             locale: getLocale(billingAddress.country, ctx.languageCode),
             lines: toMollieOrderLines(order, alreadyPaid),
+            metadata: {
+                languageCode: ctx.languageCode,
+            },
         };
         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 => {
+            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);
         const url = mollieOrder.getCheckoutUrl();
         if (!url) {
@@ -220,8 +246,18 @@ export class MollieService {
         if (!apiKey) {
             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);
+        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(
             `Processing status '${mollieOrder.status}' for order ${mollieOrder.orderNumber} for channel ${ctx.channel.token} for Mollie order ${orderId}`,
             loggerCtx,
@@ -289,7 +325,7 @@ export class MollieService {
         paymentMethodCode: string,
         status: 'Authorized' | 'Settled',
     ): Promise<Order> {
-        if (order.state !== 'ArrangingPayment') {
+        if (order.state !== 'ArrangingPayment' && order.state !== 'ArrangingAdditionalPayment') {
             const transitionToStateResult = await this.orderService.transitionToState(
                 ctx,
                 order.id,
@@ -298,7 +334,7 @@ export class MollieService {
             if (transitionToStateResult instanceof OrderStateTransitionError) {
                 throw Error(
                     `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);
         if ((result as ErrorResult).message) {
             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}`,
             );
         }
@@ -353,10 +388,10 @@ export class MollieService {
             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 additionalParams = await this.options.enabledPaymentMethodsParams?.(
-            new Injector(this.moduleRef),
+            this.injector,
             ctx,
             activeOrder ?? null,
         );
@@ -383,6 +418,96 @@ export class MollieService {
         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(
         ctx: RequestContext,
         paymentMethodCode: string,