Browse Source

fix(core): Correctly handle refunds on Orders with multiple Payments

Michael Bromley 4 years ago
parent
commit
f4ed0e7023

+ 23 - 0
packages/core/e2e/fixtures/test-payment-methods.ts

@@ -48,6 +48,29 @@ export const twoStagePaymentMethod = new PaymentMethodHandler({
     },
 });
 
+/**
+ * A method that can be used to pay for only part of the order (allowing us to test multiple payments
+ * per order).
+ */
+export const partialPaymentMethod = new PaymentMethodHandler({
+    code: 'partial-payment-method',
+    description: [{ languageCode: LanguageCode.en, value: 'Partial Payment Method' }],
+    args: {},
+    createPayment: (ctx, order, amount, args, metadata) => {
+        return {
+            amount: metadata.amount,
+            state: 'Settled',
+            transactionId: '12345',
+            metadata: { public: metadata },
+        };
+    },
+    settlePayment: () => {
+        return {
+            success: true,
+        };
+    },
+});
+
 /**
  * A payment method which includes a createRefund method.
  */

+ 16 - 1
packages/core/e2e/graphql/generated-e2e-admin-types.ts

@@ -6120,7 +6120,15 @@ export type GetOrderWithPaymentsQueryVariables = Exact<{
 
 export type GetOrderWithPaymentsQuery = {
     order?: Maybe<
-        Pick<Order, 'id'> & { payments?: Maybe<Array<Pick<Payment, 'id' | 'errorMessage' | 'metadata'>>> }
+        Pick<Order, 'id'> & {
+            payments?: Maybe<
+                Array<
+                    Pick<Payment, 'id' | 'errorMessage' | 'metadata'> & {
+                        refunds: Array<Pick<Refund, 'id' | 'total'>>;
+                    }
+                >
+            >;
+        }
     >;
 };
 
@@ -8298,6 +8306,13 @@ export namespace GetOrderWithPayments {
     export type Payments = NonNullable<
         NonNullable<NonNullable<GetOrderWithPaymentsQuery['order']>['payments']>[number]
     >;
+    export type Refunds = NonNullable<
+        NonNullable<
+            NonNullable<
+                NonNullable<NonNullable<GetOrderWithPaymentsQuery['order']>['payments']>[number]
+            >['refunds']
+        >[number]
+    >;
 }
 
 export namespace GetOrderListWithQty {

+ 123 - 0
packages/core/e2e/order.e2e-spec.ts

@@ -1,4 +1,5 @@
 /* tslint:disable:no-non-null-assertion */
+import { omit } from '@vendure/common/lib/omit';
 import { pick } from '@vendure/common/lib/pick';
 import {
     defaultShippingCalculator,
@@ -20,6 +21,7 @@ import { testConfig, TEST_SETUP_TIMEOUT_MS } from '../../../e2e-common/test-conf
 import {
     failsToSettlePaymentMethod,
     onTransitionSpy,
+    partialPaymentMethod,
     singleStageRefundablePaymentMethod,
     twoStagePaymentMethod,
 } from './fixtures/test-payment-methods';
@@ -62,6 +64,7 @@ import {
 } from './graphql/generated-e2e-admin-types';
 import {
     AddItemToOrder,
+    AddPaymentToOrder,
     ApplyCouponCode,
     DeletionResult,
     GetActiveOrder,
@@ -90,6 +93,7 @@ import {
 } from './graphql/shared-definitions';
 import {
     ADD_ITEM_TO_ORDER,
+    ADD_PAYMENT,
     APPLY_COUPON_CODE,
     GET_ACTIVE_ORDER,
     GET_ORDER_BY_CODE_WITH_PAYMENTS,
@@ -107,6 +111,7 @@ describe('Orders resolver', () => {
                 twoStagePaymentMethod,
                 failsToSettlePaymentMethod,
                 singleStageRefundablePaymentMethod,
+                partialPaymentMethod,
             ],
         },
     });
@@ -139,6 +144,10 @@ describe('Orders resolver', () => {
                         name: singleStageRefundablePaymentMethod.code,
                         handler: { code: singleStageRefundablePaymentMethod.code, arguments: [] },
                     },
+                    {
+                        name: partialPaymentMethod.code,
+                        handler: { code: partialPaymentMethod.code, arguments: [] },
+                    },
                 ],
             },
             productsCsvPath: path.join(__dirname, 'fixtures/e2e-products-full.csv'),
@@ -1713,9 +1722,119 @@ describe('Orders resolver', () => {
         });
     });
 
+    describe('multiple payments', () => {
+        const PARTIAL_PAYMENT_AMOUNT = 1000;
+        let orderId: string;
+        let orderTotalWithTax: number;
+        let payment1Id: string;
+        let payment2Id: string;
+
+        beforeAll(async () => {
+            const result = await createTestOrder(
+                adminClient,
+                shopClient,
+                customers[1].emailAddress,
+                password,
+            );
+            orderId = result.orderId;
+        });
+
+        it('adds a partial payment', async () => {
+            await proceedToArrangingPayment(shopClient);
+            const { addPaymentToOrder: order } = await shopClient.query<
+                AddPaymentToOrder.Mutation,
+                AddPaymentToOrder.Variables
+            >(ADD_PAYMENT, {
+                input: {
+                    method: partialPaymentMethod.code,
+                    metadata: {
+                        amount: PARTIAL_PAYMENT_AMOUNT,
+                    },
+                },
+            });
+            orderGuard.assertSuccess(order);
+            orderTotalWithTax = order.totalWithTax;
+
+            expect(order.state).toBe('ArrangingPayment');
+            expect(order.payments?.length).toBe(1);
+            expect(omit(order.payments![0], ['id'])).toEqual({
+                amount: PARTIAL_PAYMENT_AMOUNT,
+                metadata: {
+                    public: {
+                        amount: PARTIAL_PAYMENT_AMOUNT,
+                    },
+                },
+                method: partialPaymentMethod.code,
+                state: 'Settled',
+                transactionId: '12345',
+            });
+            payment1Id = order.payments![0].id;
+        });
+
+        it('adds another payment to make up order totalWithTax', async () => {
+            const { addPaymentToOrder: order } = await shopClient.query<
+                AddPaymentToOrder.Mutation,
+                AddPaymentToOrder.Variables
+            >(ADD_PAYMENT, {
+                input: {
+                    method: singleStageRefundablePaymentMethod.code,
+                    metadata: {},
+                },
+            });
+            orderGuard.assertSuccess(order);
+
+            expect(order.state).toBe('PaymentSettled');
+            expect(order.payments?.length).toBe(2);
+            expect(omit(order.payments![1], ['id'])).toEqual({
+                amount: orderTotalWithTax - PARTIAL_PAYMENT_AMOUNT,
+                metadata: {},
+                method: singleStageRefundablePaymentMethod.code,
+                state: 'Settled',
+                transactionId: '12345',
+            });
+            payment2Id = order.payments![1].id;
+        });
+
+        it('refunding order with multiple payments', async () => {
+            const { order } = await adminClient.query<GetOrder.Query, GetOrder.Variables>(GET_ORDER, {
+                id: orderId,
+            });
+            const { refundOrder } = await adminClient.query<RefundOrder.Mutation, RefundOrder.Variables>(
+                REFUND_ORDER,
+                {
+                    input: {
+                        lines: order!.lines.map(l => ({ orderLineId: l.id, quantity: l.quantity })),
+                        shipping: order!.shipping,
+                        adjustment: 0,
+                        reason: 'foo',
+                        paymentId: payment1Id,
+                    },
+                },
+            );
+            refundGuard.assertSuccess(refundOrder);
+            expect(refundOrder.total).toBe(PARTIAL_PAYMENT_AMOUNT);
+
+            const { order: orderWithPayments } = await adminClient.query<
+                GetOrderWithPayments.Query,
+                GetOrderWithPayments.Variables
+            >(GET_ORDER_WITH_PAYMENTS, {
+                id: orderId,
+            });
+
+            expect(orderWithPayments?.payments![0].refunds.length).toBe(1);
+            expect(orderWithPayments?.payments![0].refunds[0].total).toBe(PARTIAL_PAYMENT_AMOUNT);
+
+            expect(orderWithPayments?.payments![1].refunds.length).toBe(1);
+            expect(orderWithPayments?.payments![1].refunds[0].total).toBe(
+                orderTotalWithTax - PARTIAL_PAYMENT_AMOUNT,
+            );
+        });
+    });
+
     describe('issues', () => {
         // https://github.com/vendure-ecommerce/vendure/issues/639
         it('returns fulfillments for Order with no lines', async () => {
+            await shopClient.asAnonymousUser();
             // Apply a coupon code just to create an active order with no OrderLines
             await shopClient.query<ApplyCouponCode.Mutation, ApplyCouponCode.Variables>(APPLY_COUPON_CODE, {
                 couponCode: 'TEST',
@@ -1992,6 +2111,10 @@ const GET_ORDER_WITH_PAYMENTS = gql`
                 id
                 errorMessage
                 metadata
+                refunds {
+                    id
+                    total
+                }
             }
         }
     }

+ 70 - 37
packages/core/src/service/services/payment.service.ts

@@ -9,6 +9,7 @@ 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,
@@ -16,6 +17,7 @@ import {
 } 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 { OrderItem } from '../../entity/order-item/order-item.entity';
 import { Order } from '../../entity/order/order.entity';
 import { Payment } from '../../entity/payment/payment.entity';
@@ -174,6 +176,11 @@ export class PaymentService {
         return payment;
     }
 
+    /**
+     * Creates a Refund against the specified Payment. If the amount to be refunded exceeds the value of the
+     * specified Payment (in the case of multiple payments on a single Order), then the remaining outstanding
+     * refund amount will be refunded against the next available Payment from the Order.
+     */
     async createRefund(
         ctx: RequestContext,
         input: RefundOrderInput,
@@ -185,46 +192,72 @@ export class PaymentService {
             ctx,
             payment.method,
         );
-        const itemAmount = summate(items, 'proratedUnitPriceWithTax');
-        const refundAmount = itemAmount + input.shipping + input.adjustment;
-        let refund = new Refund({
-            payment,
-            orderItems: items,
-            items: itemAmount,
-            reason: input.reason,
-            adjustment: input.adjustment,
-            shipping: input.shipping,
-            total: refundAmount,
-            method: payment.method,
-            state: 'Pending',
-            metadata: {},
+        const orderWithRefunds = await this.connection.getEntityOrThrow(ctx, Order, order.id, {
+            relations: ['payments', 'payments.refunds'],
         });
-        const createRefundResult = await handler.createRefund(
-            ctx,
-            input,
-            refundAmount,
-            order,
-            payment,
-            paymentMethod.handler.args,
-        );
-        if (createRefundResult) {
-            refund.transactionId = createRefundResult.transactionId || '';
-            refund.metadata = createRefundResult.metadata || {};
-        }
-        refund = await this.connection.getRepository(ctx, Refund).save(refund);
-        if (createRefundResult) {
-            const fromState = refund.state;
-            try {
-                await this.refundStateMachine.transition(ctx, order, refund, createRefundResult.state);
-            } catch (e) {
-                return new RefundStateTransitionError(e.message, fromState, createRefundResult.state);
+        const existingRefunds =
+            orderWithRefunds.payments?.reduce((refunds, p) => [...refunds, ...p.refunds], [] as Refund[]) ??
+            [];
+        const itemAmount = summate(items, 'proratedUnitPriceWithTax');
+        const refundTotal = itemAmount + input.shipping + input.adjustment;
+        const refundedPaymentIds: ID[] = [];
+        let primaryRefund: Refund;
+        let refundOutstanding = refundTotal - summate(existingRefunds, 'total');
+        do {
+            const paymentToRefund =
+                refundedPaymentIds.length === 0
+                    ? payment
+                    : orderWithRefunds.payments.find(p => !refundedPaymentIds.includes(p.id));
+            if (!paymentToRefund) {
+                throw new InternalServerError(`Could not find a Payment to refund`);
             }
-            await this.connection.getRepository(ctx, Refund).save(refund, { reload: false });
-            this.eventBus.publish(
-                new RefundStateTransitionEvent(fromState, createRefundResult.state, ctx, refund, order),
+            const total = Math.min(paymentToRefund.amount, refundOutstanding);
+            let refund = new Refund({
+                payment: paymentToRefund,
+                total,
+                orderItems: items,
+                items: itemAmount,
+                reason: input.reason,
+                adjustment: input.adjustment,
+                shipping: input.shipping,
+                method: payment.method,
+                state: 'Pending',
+                metadata: {},
+            });
+            const createRefundResult = await handler.createRefund(
+                ctx,
+                input,
+                total,
+                order,
+                paymentToRefund,
+                paymentMethod.handler.args,
             );
-        }
-        return refund;
+            if (createRefundResult) {
+                refund.transactionId = createRefundResult.transactionId || '';
+                refund.metadata = createRefundResult.metadata || {};
+            }
+            refund = await this.connection.getRepository(ctx, Refund).save(refund);
+            if (createRefundResult) {
+                const fromState = refund.state;
+                try {
+                    await this.refundStateMachine.transition(ctx, order, refund, createRefundResult.state);
+                } catch (e) {
+                    return new RefundStateTransitionError(e.message, fromState, createRefundResult.state);
+                }
+                await this.connection.getRepository(ctx, Refund).save(refund, { reload: false });
+                this.eventBus.publish(
+                    new RefundStateTransitionEvent(fromState, createRefundResult.state, ctx, refund, order),
+                );
+            }
+            if (idsAreEqual(paymentToRefund.id, payment.id)) {
+                primaryRefund = refund;
+            }
+            existingRefunds.push(refund);
+            refundedPaymentIds.push(paymentToRefund.id);
+            refundOutstanding = refundTotal - summate(existingRefunds, 'total');
+        } while (0 < refundOutstanding);
+        // tslint:disable-next-line:no-non-null-assertion
+        return primaryRefund!;
     }
 
     private mergePaymentMetadata(m1: PaymentMetadata, m2?: PaymentMetadata): PaymentMetadata {