Skip to content

Commit

Permalink
fix(core): Correctly handle refunds on Orders with multiple Payments
Browse files Browse the repository at this point in the history
  • Loading branch information
michaelbromley committed Mar 16, 2021
1 parent 2bd289a commit f4ed0e7
Show file tree
Hide file tree
Showing 4 changed files with 232 additions and 38 deletions.
23 changes: 23 additions & 0 deletions packages/core/e2e/fixtures/test-payment-methods.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
*/
Expand Down
17 changes: 16 additions & 1 deletion packages/core/e2e/graphql/generated-e2e-admin-types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'>>;
}
>
>;
}
>;
};

Expand Down Expand Up @@ -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 {
Expand Down
123 changes: 123 additions & 0 deletions packages/core/e2e/order.e2e-spec.ts
Original file line number Diff line number Diff line change
@@ -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,
Expand All @@ -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';
Expand Down Expand Up @@ -62,6 +64,7 @@ import {
} from './graphql/generated-e2e-admin-types';
import {
AddItemToOrder,
AddPaymentToOrder,
ApplyCouponCode,
DeletionResult,
GetActiveOrder,
Expand Down Expand Up @@ -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,
Expand All @@ -107,6 +111,7 @@ describe('Orders resolver', () => {
twoStagePaymentMethod,
failsToSettlePaymentMethod,
singleStageRefundablePaymentMethod,
partialPaymentMethod,
],
},
});
Expand Down Expand Up @@ -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'),
Expand Down Expand Up @@ -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',
Expand Down Expand Up @@ -1992,6 +2111,10 @@ const GET_ORDER_WITH_PAYMENTS = gql`
id
errorMessage
metadata
refunds {
id
total
}
}
}
}
Expand Down
107 changes: 70 additions & 37 deletions packages/core/src/service/services/payment.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,13 +9,15 @@ 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 { OrderItem } from '../../entity/order-item/order-item.entity';
import { Order } from '../../entity/order/order.entity';
import { Payment } from '../../entity/payment/payment.entity';
Expand Down Expand Up @@ -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,
Expand All @@ -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 {
Expand Down

0 comments on commit f4ed0e7

Please sign in to comment.