diff --git a/packages/core/e2e/fixtures/test-payment-methods.ts b/packages/core/e2e/fixtures/test-payment-methods.ts index 245d4a37ae..deb50eb070 100644 --- a/packages/core/e2e/fixtures/test-payment-methods.ts +++ b/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. */ diff --git a/packages/core/e2e/graphql/generated-e2e-admin-types.ts b/packages/core/e2e/graphql/generated-e2e-admin-types.ts index 51c9668df1..a88e2e7d18 100644 --- a/packages/core/e2e/graphql/generated-e2e-admin-types.ts +++ b/packages/core/e2e/graphql/generated-e2e-admin-types.ts @@ -6120,7 +6120,15 @@ export type GetOrderWithPaymentsQueryVariables = Exact<{ export type GetOrderWithPaymentsQuery = { order?: Maybe< - Pick & { payments?: Maybe>> } + Pick & { + payments?: Maybe< + Array< + Pick & { + refunds: Array>; + } + > + >; + } >; }; @@ -8298,6 +8306,13 @@ export namespace GetOrderWithPayments { export type Payments = NonNullable< NonNullable['payments']>[number] >; + export type Refunds = NonNullable< + NonNullable< + NonNullable< + NonNullable['payments']>[number] + >['refunds'] + >[number] + >; } export namespace GetOrderListWithQty { diff --git a/packages/core/e2e/order.e2e-spec.ts b/packages/core/e2e/order.e2e-spec.ts index a63ef8a167..fa495c9ecd 100644 --- a/packages/core/e2e/order.e2e-spec.ts +++ b/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(GET_ORDER, { + id: orderId, + }); + const { refundOrder } = await adminClient.query( + 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(APPLY_COUPON_CODE, { couponCode: 'TEST', @@ -1992,6 +2111,10 @@ const GET_ORDER_WITH_PAYMENTS = gql` id errorMessage metadata + refunds { + id + total + } } } } diff --git a/packages/core/src/service/services/payment.service.ts b/packages/core/src/service/services/payment.service.ts index 07d8ba57fa..c79ebf8013 100644 --- a/packages/core/src/service/services/payment.service.ts +++ b/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 {