From 34b61cd0a77189d82ff86866a8411821b5938116 Mon Sep 17 00:00:00 2001 From: Martijn Date: Mon, 4 Mar 2024 10:21:35 +0100 Subject: [PATCH] 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. --- packages/core/src/service/index.ts | 1 + .../e2e/graphql/shop-queries.ts | 19 ++ .../payments-plugin/e2e/mollie-dev-server.ts | 37 +++- .../e2e/mollie-payment.e2e-spec.ts | 112 +++++++--- .../src/mollie/custom-fields.ts | 16 ++ .../src/mollie/extended-mollie-client.ts | 76 +++++++ .../src/mollie/mollie.helpers.ts | 37 ++-- .../src/mollie/mollie.plugin.ts | 16 +- .../src/mollie/mollie.service.ts | 203 ++++++++++++++---- 9 files changed, 427 insertions(+), 90 deletions(-) create mode 100644 packages/payments-plugin/src/mollie/custom-fields.ts create mode 100644 packages/payments-plugin/src/mollie/extended-mollie-client.ts diff --git a/packages/core/src/service/index.ts b/packages/core/src/service/index.ts index 4574d04ecf..8e442c9be1 100644 --- a/packages/core/src/service/index.ts +++ b/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'; diff --git a/packages/payments-plugin/e2e/graphql/shop-queries.ts b/packages/payments-plugin/e2e/graphql/shop-queries.ts index adf61b965a..2d0879cdea 100644 --- a/packages/payments-plugin/e2e/graphql/shop-queries.ts +++ b/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) { diff --git a/packages/payments-plugin/e2e/mollie-dev-server.ts b/packages/payments-plugin/e2e/mollie-dev-server.ts index 96dd94eb22..19166516f7 100644 --- a/packages/payments-plugin/e2e/mollie-dev-server.ts +++ b/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(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(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 () => { diff --git a/packages/payments-plugin/e2e/mollie-payment.e2e-spec.ts b/packages/payments-plugin/e2e/mollie-payment.e2e-spec.ts index 87c6bced31..e63c961be3 100644 --- a/packages/payments-plugin/e2e/mollie-payment.e2e-spec.ts +++ b/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( + const { orderByCode } = await shopClient.query( 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( - 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(GET_CUSTOMER_LIST, { + } = await adminClient.query(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( - 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, diff --git a/packages/payments-plugin/src/mollie/custom-fields.ts b/packages/payments-plugin/src/mollie/custom-fields.ts new file mode 100644 index 0000000000..a92eb820b6 --- /dev/null +++ b/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, + }, +]; diff --git a/packages/payments-plugin/src/mollie/extended-mollie-client.ts b/packages/payments-plugin/src/mollie/extended-mollie-client.ts new file mode 100644 index 0000000000..9cd145ed52 --- /dev/null +++ b/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 => { + const instance = axios.create({ + baseURL: `https://api.mollie.com`, + timeout: 5000, + headers: { + 'Content-Type': 'application/json', + Authorization: `Bearer ${options.apiKey}`, + }, + validateStatus: () => true, // We handle errors ourselves, for better error messages + }); + const {status, data} = await instance.patch(`/v2/orders/${orderId}/lines`, input); + if (status < 200 || status > 300) { + throw Error(JSON.stringify(data, null, 2)) + } + return data; + } + return client; +} + + +export interface ExtendedMollieClient extends MollieClient { + /** + * Update all order lines in 1 request. + */ + manageOrderLines(orderId: string, input: ManageOrderLineInput): Promise; +} + +interface CancelOperation { + operation: 'cancel'; + data: { id: string } +} + +interface UpdateOperation { + operation: 'update'; + data: { + id: string + name?: string + quantity?: number, + unitPrice?: Amount + totalAmount?: Amount + vatRate?: string + vatAmount?: Amount + } +} + +interface AddOperation { + operation: 'add'; + data: { + name: string + quantity: number, + unitPrice: Amount + totalAmount: Amount + vatRate: string + vatAmount: Amount + } +} + +export interface ManageOrderLineInput { + operations: Array +} diff --git a/packages/payments-plugin/src/mollie/mollie.helpers.ts b/packages/payments-plugin/src/mollie/mollie.helpers.ts index 6b8612cc77..321bda73ef 100644 --- a/packages/payments-plugin/src/mollie/mollie.helpers.ts +++ b/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 + ); +} diff --git a/packages/payments-plugin/src/mollie/mollie.plugin.ts b/packages/payments-plugin/src/mollie/mollie.plugin.ts index 2342ca468b..228ad999af 100644 --- a/packages/payments-plugin/src/mollie/mollie.plugin.ts +++ b/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: { diff --git a/packages/payments-plugin/src/mollie/mollie.service.ts b/packages/payments-plugin/src/mollie/mollie.service.ts index 0491fffa5d..76790a761f 100644 --- a/packages/payments-plugin/src/mollie/mollie.service.ts +++ b/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 { - 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 { + const existingMollieOrder = await mollieClient.orders.get(mollieOrderId); + const [order] = await Promise.all([ + this.updateMollieOrderData(mollieClient, existingMollieOrder, newMollieOrderInput), + this.updateMollieOrderLines(mollieClient, existingMollieOrder, newMollieOrderInput.lines), + ]); + return order; + } + + /** + * Update the Mollie Order data itself, excluding the order lines. + * So, addresses, redirect url etc + */ + private async updateMollieOrderData( + mollieClient: ExtendedMollieClient, + existingMollieOrder: MollieOrder, + newMollieOrderInput: CreateParameters + ): Promise { + return await mollieClient.orders.update(existingMollieOrder.id, { + billingAddress: newMollieOrderInput.billingAddress, + shippingAddress: newMollieOrderInput.shippingAddress, + redirectUrl: newMollieOrderInput.redirectUrl, + }); + } + + /** + * 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 { + 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,