Procházet zdrojové kódy

fix(core): Fix multiple refunds bug when modifying orders

Fixes #1753
Michael Bromley před 3 roky
rodič
revize
f18feddd9f

+ 145 - 1
packages/core/e2e/order-modification.e2e-spec.ts

@@ -1269,6 +1269,150 @@ describe('Order modification', () => {
         });
     });
 
+    // https://github.com/vendure-ecommerce/vendure/issues/1753
+    describe('refunds for multiple payments', () => {
+        let orderId2: string;
+        let orderLineId: string;
+        let additionalPaymentId: string;
+
+        beforeAll(async () => {
+            await adminClient.query<CreatePromotion.Mutation, CreatePromotion.Variables>(CREATE_PROMOTION, {
+                input: {
+                    name: '$5 off',
+                    couponCode: '5OFF',
+                    enabled: true,
+                    conditions: [],
+                    actions: [
+                        {
+                            code: orderFixedDiscount.code,
+                            arguments: [{ name: 'discount', value: '500' }],
+                        },
+                    ],
+                },
+            });
+            await shopClient.asUserWithCredentials('trevor_donnelly96@hotmail.com', 'test');
+            await shopClient.query(gql(ADD_ITEM_TO_ORDER_WITH_CUSTOM_FIELDS), {
+                productVariantId: 'T_5',
+                quantity: 1,
+            } as any);
+            await proceedToArrangingPayment(shopClient);
+            const order = await addPaymentToOrder(shopClient, testSuccessfulPaymentMethod);
+            orderGuard.assertSuccess(order);
+            orderLineId = order.lines[0].id;
+            orderId2 = order.id;
+
+            const transitionOrderToState = await adminTransitionOrderToState(orderId2, 'Modifying');
+            orderGuard.assertSuccess(transitionOrderToState);
+            const { modifyOrder } = await adminClient.query<ModifyOrder.Mutation, ModifyOrder.Variables>(
+                MODIFY_ORDER,
+                {
+                    input: {
+                        dryRun: false,
+                        orderId: orderId2,
+                        adjustOrderLines: [{ orderLineId, quantity: 2 }],
+                    },
+                },
+            );
+            orderGuard.assertSuccess(modifyOrder);
+
+            await adminTransitionOrderToState(orderId2, 'ArrangingAdditionalPayment');
+
+            const { addManualPaymentToOrder } = await adminClient.query<
+                AddManualPayment.Mutation,
+                AddManualPayment.Variables
+            >(ADD_MANUAL_PAYMENT, {
+                input: {
+                    orderId: orderId2,
+                    method: 'test',
+                    transactionId: 'ABC123',
+                    metadata: {
+                        foo: 'bar',
+                    },
+                },
+            });
+            orderGuard.assertSuccess(addManualPaymentToOrder);
+            additionalPaymentId = addManualPaymentToOrder.payments?.[1].id!;
+
+            const transitionOrderToState2 = await adminTransitionOrderToState(orderId2, 'PaymentSettled');
+            orderGuard.assertSuccess(transitionOrderToState2);
+
+            expect(transitionOrderToState2.state).toBe('PaymentSettled');
+        });
+
+        it('apply couponCode to create first refund', async () => {
+            const transitionOrderToState = await adminTransitionOrderToState(orderId2, 'Modifying');
+            orderGuard.assertSuccess(transitionOrderToState);
+            const { modifyOrder } = await adminClient.query<ModifyOrder.Mutation, ModifyOrder.Variables>(
+                MODIFY_ORDER,
+                {
+                    input: {
+                        dryRun: false,
+                        orderId: orderId2,
+                        couponCodes: ['5OFF'],
+                        refund: {
+                            paymentId: additionalPaymentId,
+                            reason: 'test',
+                        },
+                    },
+                },
+            );
+            orderGuard.assertSuccess(modifyOrder);
+
+            expect(modifyOrder.payments?.length).toBe(2);
+            expect(modifyOrder?.payments?.find(p => p.id === additionalPaymentId)?.refunds).toEqual([
+                {
+                    id: 'T_4',
+                    paymentId: additionalPaymentId,
+                    state: 'Pending',
+                    total: 600,
+                },
+            ]);
+            expect(modifyOrder.totalWithTax).toBe(getOrderPaymentsTotalWithRefunds(modifyOrder));
+        });
+
+        it('reduce quantity to create second refund', async () => {
+            const { modifyOrder } = await adminClient.query<ModifyOrder.Mutation, ModifyOrder.Variables>(
+                MODIFY_ORDER,
+                {
+                    input: {
+                        dryRun: false,
+                        orderId: orderId2,
+                        adjustOrderLines: [{ orderLineId, quantity: 1 }],
+                        refund: {
+                            paymentId: additionalPaymentId,
+                            reason: 'test 2',
+                        },
+                    },
+                },
+            );
+            orderGuard.assertSuccess(modifyOrder);
+
+            expect(modifyOrder?.payments?.find(p => p.id === additionalPaymentId)?.refunds).toEqual([
+                {
+                    id: 'T_4',
+                    paymentId: additionalPaymentId,
+                    state: 'Pending',
+                    total: 600,
+                },
+                {
+                    id: 'T_5',
+                    paymentId: additionalPaymentId,
+                    state: 'Pending',
+                    total: 16649,
+                },
+            ]);
+            expect(modifyOrder?.payments?.find(p => p.id !== additionalPaymentId)?.refunds).toEqual([
+                {
+                    id: 'T_6',
+                    paymentId: 'T_15',
+                    state: 'Pending',
+                    total: 300,
+                },
+            ]);
+            expect(modifyOrder.totalWithTax).toBe(getOrderPaymentsTotalWithRefunds(modifyOrder));
+        });
+    });
+
     // https://github.com/vendure-ecommerce/vendure/issues/688 - 4th point
     it('correct additional payment when discounts applied', async () => {
         await adminClient.query<CreatePromotion.Mutation, CreatePromotion.Variables>(CREATE_PROMOTION, {
@@ -2012,7 +2156,7 @@ describe('Order modification', () => {
             expect(history.history.items.length).toBe(1);
             expect(pick(history.history.items[0]!, ['type', 'data'])).toEqual({
                 type: HistoryEntryType.ORDER_COUPON_APPLIED,
-                data: { couponCode: CODE_50PC_OFF, promotionId: 'T_5' },
+                data: { couponCode: CODE_50PC_OFF, promotionId: 'T_6' },
             });
         });
 

+ 32 - 27
packages/core/src/service/services/payment.service.ts

@@ -1,25 +1,20 @@
 import { Injectable } from '@nestjs/common';
-import {
-    CancelPaymentResult,
-    ManualPaymentInput,
-    RefundOrderInput,
-    SettlePaymentResult,
-} from '@vendure/common/lib/generated-types';
+import { ManualPaymentInput, RefundOrderInput } from '@vendure/common/lib/generated-types';
 import { DeepPartial, ID } from '@vendure/common/lib/shared-types';
 import { summate } from '@vendure/common/lib/shared-utils';
 
 import { RequestContext } from '../../api/common/request-context';
-import { ErrorResultUnion } from '../../common/error/error-result';
 import { InternalServerError } from '../../common/error/errors';
 import {
     PaymentStateTransitionError,
     RefundStateTransitionError,
-    SettlePaymentError,
 } from '../../common/error/generated-graphql-admin-errors';
 import { IneligiblePaymentMethodError } from '../../common/error/generated-graphql-shop-errors';
 import { PaymentMetadata } from '../../common/types/common-types';
 import { idsAreEqual } from '../../common/utils';
+import { Logger, PaymentMethodHandler } from '../../config/index';
 import { TransactionalConnection } from '../../connection/transactional-connection';
+import { PaymentMethod } from '../../entity/index';
 import { OrderItem } from '../../entity/order-item/order-item.entity';
 import { Order } from '../../entity/order/order.entity';
 import { Payment } from '../../entity/payment/payment.entity';
@@ -275,10 +270,7 @@ export class PaymentService {
             return summate(nonFailedRefunds, 'total');
         }
 
-        const existingNonFailedRefunds =
-            orderWithRefunds.payments
-                ?.reduce((refunds, p) => [...refunds, ...p.refunds], [] as Refund[])
-                .filter(refund => refund.state !== 'Failed') ?? [];
+        const refundsCreated: Refund[] = [];
         const refundablePayments = orderWithRefunds.payments.filter(p => {
             return paymentRefundTotal(p) < p.amount;
         });
@@ -314,19 +306,32 @@ export class PaymentService {
                 state: 'Pending',
                 metadata: {},
             });
-            const { paymentMethod, handler } = await this.paymentMethodService.getMethodAndOperations(
-                ctx,
-                paymentToRefund.method,
-            );
-            const createRefundResult = await handler.createRefund(
-                ctx,
-                input,
-                total,
-                order,
-                paymentToRefund,
-                paymentMethod.handler.args,
-                paymentMethod,
-            );
+            let paymentMethod: PaymentMethod | undefined;
+            let handler: PaymentMethodHandler | undefined;
+            try {
+                const methodAndHandler = await this.paymentMethodService.getMethodAndOperations(
+                    ctx,
+                    paymentToRefund.method,
+                );
+                paymentMethod = methodAndHandler.paymentMethod;
+                handler = methodAndHandler.handler;
+            } catch (e) {
+                Logger.warn(
+                    `Could not find a corresponding PaymentMethodHandler when creating a refund for the Payment with method "${paymentToRefund.method}"`,
+                );
+            }
+            const createRefundResult =
+                paymentMethod && handler
+                    ? await handler.createRefund(
+                          ctx,
+                          input,
+                          total,
+                          order,
+                          paymentToRefund,
+                          paymentMethod.handler.args,
+                          paymentMethod,
+                      )
+                    : false;
             if (createRefundResult) {
                 refund.transactionId = createRefundResult.transactionId || '';
                 refund.metadata = createRefundResult.metadata || {};
@@ -347,9 +352,9 @@ export class PaymentService {
             if (primaryRefund == null) {
                 primaryRefund = refund;
             }
-            existingNonFailedRefunds.push(refund);
+            refundsCreated.push(refund);
             refundedPaymentIds.push(paymentToRefund.id);
-            refundOutstanding = refundTotal - summate(existingNonFailedRefunds, 'total');
+            refundOutstanding = refundTotal - summate(refundsCreated, 'total');
         } while (0 < refundOutstanding);
         // tslint:disable-next-line:no-non-null-assertion
         return primaryRefund!;