Browse Source

feat(payments-plugin): Live testing of duplicate payments

Martijn 1 year ago
parent
commit
9c1df83018

+ 13 - 2
packages/payments-plugin/e2e/mollie-dev-server.ts

@@ -31,6 +31,7 @@ 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) {
@@ -114,8 +115,7 @@ async function runMollieDevServer(useDynamicRedirectUrl: boolean) {
         channel: await server.app.get(ChannelService).getDefaultChannel(),
     });
     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 +128,17 @@ async function runMollieDevServer(useDynamicRedirectUrl: boolean) {
     }
     // eslint-disable-next-line no-console
     console.log('\x1b[41m', `Mollie payment link: ${createMolliePaymentIntent.url as string}`, '\x1b[0m');
+
+    // Create another intent after 10s, should cancel the previous Mollie order
+    await new Promise(resolve => setTimeout(resolve, 10000));
+    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 () => {

+ 23 - 11
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';
@@ -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,18 @@ export function toAmount(value: number, orderCurrency: string): Amount {
 }
 
 /**
- * Return to number of cents
+ * Checks if the Vendure order amount due is the same as the given Mollie amount.
+ * E.g. does '1000 EUR' equal { value: '10.00', currency: 'EUR'}?
+ */
+export function isAmountEqual(orderCurrency: CurrencyCode, amountDue: number, mollieAmount: Amount): boolean {
+    if (orderCurrency !== mollieAmount.currency) {
+        return false;
+    }
+    return amountToCents(mollieAmount) === amountDue;
+}
+
+/**
+ * Return to number of cents. E.g. '10.00' => 1000
  */
 export function amountToCents(amount: Amount): number {
     return currency(amount.value).intValue;
@@ -99,7 +112,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
-
 }
 
 /**

+ 2 - 4
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';
@@ -214,10 +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({
-        //     name: 'activeMollieOrderId',
-        //     type: 'string',
-        // })
+        config.customFields.Order.push(...orderCustomFields);
         return config;
     },
     shopApiExtensions: {

+ 64 - 6
packages/payments-plugin/src/mollie/mollie.service.ts

@@ -2,7 +2,7 @@ import createMollieClient, {
     Order as MollieOrder,
     OrderStatus,
     PaymentMethod as MollieClientMethod,
-    Locale,
+    MollieClient,
 } from '@mollie/api-client';
 import { CreateParameters } from '@mollie/api-client/dist/types/src/binders/orders/parameters';
 import { Inject, Injectable } from '@nestjs/common';
@@ -29,6 +29,7 @@ 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,
@@ -36,7 +37,14 @@ import {
     MolliePaymentIntentResult,
     MolliePaymentMethod,
 } from './graphql/generated-shop-types';
-import { amountToCents, getLocale, toAmount, toMollieAddress, toMollieOrderLines } from './mollie.helpers';
+import {
+    amountToCents,
+    getLocale,
+    isAmountEqual,
+    toAmount,
+    toMollieAddress,
+    toMollieOrderLines,
+} from './mollie.helpers';
 import { MolliePluginOptions } from './mollie.plugin';
 
 interface OrderStatusInput {
@@ -87,10 +95,11 @@ export class MollieService {
                 `molliePaymentMethodCode has to be one of "${allowedMethods.join(',')}"`,
             );
         }
-        const [order, paymentMethod] = await Promise.all([
-            this.activeOrderService.getActiveOrder(ctx, undefined),
-            this.getPaymentMethod(ctx, paymentMethodCode),
-        ]);
+        const [order, paymentMethod]: [OrderWithMollieReference | undefined, PaymentMethod | undefined] =
+            await Promise.all([
+                this.activeOrderService.getActiveOrder(ctx, undefined),
+                this.getPaymentMethod(ctx, paymentMethodCode),
+            ]);
         if (!order) {
             return new PaymentIntentError('No active order found for session');
         }
@@ -202,7 +211,28 @@ export class MollieService {
         if (molliePaymentMethodCode) {
             orderInput.method = molliePaymentMethodCode as MollieClientMethod;
         }
+        if (order.customFields?.mollieOrderId) {
+            // A payment was already started, so we try to reuse the existing order
+
+            // FIXME make this failsafe: reusing should never throw and fail payment intent creation
+            const existingMollieOrder = await mollieClient.orders.get(order.customFields.mollieOrderId);
+            const checkoutUrl = existingMollieOrder.getCheckoutUrl();
+            const amountsMatch = isAmountEqual(order.currencyCode, amountToPay, existingMollieOrder.amount);
+            if (checkoutUrl && amountsMatch) {
+                return {
+                    url: checkoutUrl,
+                };
+            }
+            // Otherwise, cancel existing Mollie order asynchronously, because we don't care if it fails
+            this.cancelMollieOrder(mollieClient, order.customFields.mollieOrderId).catch(e => {
+                Logger.warn(`Failed to cancel existing Mollie order: ${(e as Error).message}`, loggerCtx);
+            });
+        }
         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) {
@@ -407,6 +437,34 @@ export class MollieService {
         return variantsWithInsufficientSaleableStock;
     }
 
+    /**
+     * Tries to cancel an existing Mollie order
+     * An order might not be cancellable when it has open payments, and open payments can't be cancelled
+     * It takes at least 15 minutes for a payment to expire can be cancelled: https://docs.mollie.com/payments/status-changes#when-does-a-payment-expire
+     */
+    async cancelMollieOrder(client: MollieClient, mollieOrderId: string): Promise<void> {
+        const mollieOrder = await client.orders.get(mollieOrderId);
+        if (mollieOrder.isCancelable) {
+            await client.orders.cancel(mollieOrder.id);
+        } else {
+            // Try to cancel all payments
+            const payments = await mollieOrder.getPayments();
+            await Promise.all(
+                payments.map(async payment => {
+                    if (!payment.isCancelable) {
+                        throw Error(
+                            `Payment ${payment.id} for Mollie order '${mollieOrderId}' is not cancellable`,
+                        );
+                    }
+                    await client.payments.cancel(payment.id);
+                }),
+            );
+            // Try to cancel order again
+            await client.orders.cancel(mollieOrder.id);
+        }
+        Logger.info(`Cancelled Mollie order ${mollieOrder.id}`, loggerCtx);
+    }
+
     private async canTransitionTo(ctx: RequestContext, order: Order, state: OrderState) {
         // Fetch new order object, because `transition()` mutates the order object
         const orderCopy = await assertFound(this.orderService.findOne(ctx, order.id));