浏览代码

feat(core): Add `amount` field to `RefundOrderInput`

Relates to #2393
Michael Bromley 2 年之前
父节点
当前提交
fe43b4a711

+ 1 - 0
packages/admin-ui/src/lib/catalog/src/public_api.ts

@@ -20,6 +20,7 @@ export * from './components/collection-tree/collection-tree.component';
 export * from './components/collection-tree/collection-tree.service';
 export * from './components/collection-tree/collection-tree.types';
 export * from './components/confirm-variant-deletion-dialog/confirm-variant-deletion-dialog.component';
+export * from './components/create-facet-value-dialog/create-facet-value-dialog.component';
 export * from './components/create-product-option-group-dialog/create-product-option-group-dialog.component';
 export * from './components/create-product-variant-dialog/create-product-variant-dialog.component';
 export * from './components/facet-detail/facet-detail.component';

文件差异内容过多而无法显示
+ 20 - 3
packages/admin-ui/src/lib/core/src/common/generated-types.ts


+ 2 - 0
packages/admin-ui/src/lib/core/src/common/introspection-result.ts

@@ -120,6 +120,7 @@
       "PaymentStateTransitionError",
       "ProductOptionInUseError",
       "QuantityTooGreatError",
+      "RefundAmountError",
       "RefundOrderStateError",
       "RefundPaymentIdMissingError",
       "RefundStateTransitionError",
@@ -223,6 +224,7 @@
       "PaymentOrderMismatchError",
       "QuantityTooGreatError",
       "Refund",
+      "RefundAmountError",
       "RefundOrderStateError",
       "RefundStateTransitionError"
     ],

+ 1 - 0
packages/admin-ui/src/lib/core/src/public_api.ts

@@ -95,6 +95,7 @@ export * from './providers/bulk-action-registry/bulk-action-registry.service';
 export * from './providers/bulk-action-registry/bulk-action-types';
 export * from './providers/channel/channel.service';
 export * from './providers/component-registry/component-registry.service';
+export * from './providers/currency/currency.service';
 export * from './providers/custom-detail-component/custom-detail-component-types';
 export * from './providers/custom-detail-component/custom-detail-component.service';
 export * from './providers/custom-field-component/custom-field-component.service';

+ 16 - 1
packages/asset-server-plugin/e2e/graphql/generated-e2e-asset-server-plugin-types.ts

@@ -1546,6 +1546,7 @@ export enum ErrorCode {
   PAYMENT_STATE_TRANSITION_ERROR = 'PAYMENT_STATE_TRANSITION_ERROR',
   PRODUCT_OPTION_IN_USE_ERROR = 'PRODUCT_OPTION_IN_USE_ERROR',
   QUANTITY_TOO_GREAT_ERROR = 'QUANTITY_TOO_GREAT_ERROR',
+  REFUND_AMOUNT_ERROR = 'REFUND_AMOUNT_ERROR',
   REFUND_ORDER_STATE_ERROR = 'REFUND_ORDER_STATE_ERROR',
   REFUND_PAYMENT_ID_MISSING_ERROR = 'REFUND_PAYMENT_ID_MISSING_ERROR',
   REFUND_STATE_TRANSITION_ERROR = 'REFUND_STATE_TRANSITION_ERROR',
@@ -5153,6 +5154,13 @@ export type Refund = Node & {
   updatedAt: Scalars['DateTime']['output'];
 };
 
+/** Returned if `amount` is greater than the maximum un-refunded amount of the Payment */
+export type RefundAmountError = ErrorResult & {
+  errorCode: ErrorCode;
+  maximumRefundable: Scalars['Int']['output'];
+  message: Scalars['String']['output'];
+};
+
 export type RefundLine = {
   orderLine: OrderLine;
   orderLineId: Scalars['ID']['output'];
@@ -5163,13 +5171,20 @@ export type RefundLine = {
 
 export type RefundOrderInput = {
   adjustment: Scalars['Money']['input'];
+  /**
+   * If an amount is specified, this value will be used to create a Refund rather than calculating the
+   * amount automatically. This was added in v2.2 and will be the preferred way to specify the refund
+   * amount in the future. The `lines`, `shipping` and `adjustment` fields will likely be removed in a future
+   * version.
+   */
+  amount?: InputMaybe<Scalars['Money']['input']>;
   lines: Array<OrderLineInput>;
   paymentId: Scalars['ID']['input'];
   reason?: InputMaybe<Scalars['String']['input']>;
   shipping: Scalars['Money']['input'];
 };
 
-export type RefundOrderResult = AlreadyRefundedError | MultipleOrderError | NothingToRefundError | OrderStateTransitionError | PaymentOrderMismatchError | QuantityTooGreatError | Refund | RefundOrderStateError | RefundStateTransitionError;
+export type RefundOrderResult = AlreadyRefundedError | MultipleOrderError | NothingToRefundError | OrderStateTransitionError | PaymentOrderMismatchError | QuantityTooGreatError | Refund | RefundAmountError | RefundOrderStateError | RefundStateTransitionError;
 
 /** Returned if an attempting to refund an Order which is not in the expected state */
 export type RefundOrderStateError = ErrorResult & {

+ 17 - 1
packages/common/src/generated-types.ts

@@ -1589,6 +1589,7 @@ export enum ErrorCode {
   PAYMENT_STATE_TRANSITION_ERROR = 'PAYMENT_STATE_TRANSITION_ERROR',
   PRODUCT_OPTION_IN_USE_ERROR = 'PRODUCT_OPTION_IN_USE_ERROR',
   QUANTITY_TOO_GREAT_ERROR = 'QUANTITY_TOO_GREAT_ERROR',
+  REFUND_AMOUNT_ERROR = 'REFUND_AMOUNT_ERROR',
   REFUND_ORDER_STATE_ERROR = 'REFUND_ORDER_STATE_ERROR',
   REFUND_PAYMENT_ID_MISSING_ERROR = 'REFUND_PAYMENT_ID_MISSING_ERROR',
   REFUND_STATE_TRANSITION_ERROR = 'REFUND_STATE_TRANSITION_ERROR',
@@ -5282,6 +5283,14 @@ export type Refund = Node & {
   updatedAt: Scalars['DateTime']['output'];
 };
 
+/** Returned if `amount` is greater than the maximum un-refunded amount of the Payment */
+export type RefundAmountError = ErrorResult & {
+  __typename?: 'RefundAmountError';
+  errorCode: ErrorCode;
+  maximumRefundable: Scalars['Int']['output'];
+  message: Scalars['String']['output'];
+};
+
 export type RefundLine = {
   __typename?: 'RefundLine';
   orderLine: OrderLine;
@@ -5293,13 +5302,20 @@ export type RefundLine = {
 
 export type RefundOrderInput = {
   adjustment: Scalars['Money']['input'];
+  /**
+   * If an amount is specified, this value will be used to create a Refund rather than calculating the
+   * amount automatically. This was added in v2.2 and will be the preferred way to specify the refund
+   * amount in the future. The `lines`, `shipping` and `adjustment` fields will likely be removed in a future
+   * version.
+   */
+  amount?: InputMaybe<Scalars['Money']['input']>;
   lines: Array<OrderLineInput>;
   paymentId: Scalars['ID']['input'];
   reason?: InputMaybe<Scalars['String']['input']>;
   shipping: Scalars['Money']['input'];
 };
 
-export type RefundOrderResult = AlreadyRefundedError | MultipleOrderError | NothingToRefundError | OrderStateTransitionError | PaymentOrderMismatchError | QuantityTooGreatError | Refund | RefundOrderStateError | RefundStateTransitionError;
+export type RefundOrderResult = AlreadyRefundedError | MultipleOrderError | NothingToRefundError | OrderStateTransitionError | PaymentOrderMismatchError | QuantityTooGreatError | Refund | RefundAmountError | RefundOrderStateError | RefundStateTransitionError;
 
 /** Returned if an attempting to refund an Order which is not in the expected state */
 export type RefundOrderStateError = ErrorResult & {

+ 17 - 2
packages/core/e2e/graphql/generated-e2e-admin-types.ts

@@ -1546,6 +1546,7 @@ export enum ErrorCode {
   PAYMENT_STATE_TRANSITION_ERROR = 'PAYMENT_STATE_TRANSITION_ERROR',
   PRODUCT_OPTION_IN_USE_ERROR = 'PRODUCT_OPTION_IN_USE_ERROR',
   QUANTITY_TOO_GREAT_ERROR = 'QUANTITY_TOO_GREAT_ERROR',
+  REFUND_AMOUNT_ERROR = 'REFUND_AMOUNT_ERROR',
   REFUND_ORDER_STATE_ERROR = 'REFUND_ORDER_STATE_ERROR',
   REFUND_PAYMENT_ID_MISSING_ERROR = 'REFUND_PAYMENT_ID_MISSING_ERROR',
   REFUND_STATE_TRANSITION_ERROR = 'REFUND_STATE_TRANSITION_ERROR',
@@ -5153,6 +5154,13 @@ export type Refund = Node & {
   updatedAt: Scalars['DateTime']['output'];
 };
 
+/** Returned if `amount` is greater than the maximum un-refunded amount of the Payment */
+export type RefundAmountError = ErrorResult & {
+  errorCode: ErrorCode;
+  maximumRefundable: Scalars['Int']['output'];
+  message: Scalars['String']['output'];
+};
+
 export type RefundLine = {
   orderLine: OrderLine;
   orderLineId: Scalars['ID']['output'];
@@ -5163,13 +5171,20 @@ export type RefundLine = {
 
 export type RefundOrderInput = {
   adjustment: Scalars['Money']['input'];
+  /**
+   * If an amount is specified, this value will be used to create a Refund rather than calculating the
+   * amount automatically. This was added in v2.2 and will be the preferred way to specify the refund
+   * amount in the future. The `lines`, `shipping` and `adjustment` fields will likely be removed in a future
+   * version.
+   */
+  amount?: InputMaybe<Scalars['Money']['input']>;
   lines: Array<OrderLineInput>;
   paymentId: Scalars['ID']['input'];
   reason?: InputMaybe<Scalars['String']['input']>;
   shipping: Scalars['Money']['input'];
 };
 
-export type RefundOrderResult = AlreadyRefundedError | MultipleOrderError | NothingToRefundError | OrderStateTransitionError | PaymentOrderMismatchError | QuantityTooGreatError | Refund | RefundOrderStateError | RefundStateTransitionError;
+export type RefundOrderResult = AlreadyRefundedError | MultipleOrderError | NothingToRefundError | OrderStateTransitionError | PaymentOrderMismatchError | QuantityTooGreatError | Refund | RefundAmountError | RefundOrderStateError | RefundStateTransitionError;
 
 /** Returned if an attempting to refund an Order which is not in the expected state */
 export type RefundOrderStateError = ErrorResult & {
@@ -7597,7 +7612,7 @@ export type RefundOrderMutationVariables = Exact<{
 }>;
 
 
-export type RefundOrderMutation = { refundOrder: { errorCode: ErrorCode, message: string } | { errorCode: ErrorCode, message: string } | { errorCode: ErrorCode, message: string } | { errorCode: ErrorCode, message: string } | { errorCode: ErrorCode, message: string } | { errorCode: ErrorCode, message: string } | { id: string, state: string, items: number, transactionId?: string | null, shipping: number, total: number, metadata?: any | null } | { errorCode: ErrorCode, message: string } | { errorCode: ErrorCode, message: string } };
+export type RefundOrderMutation = { refundOrder: { errorCode: ErrorCode, message: string } | { errorCode: ErrorCode, message: string } | { errorCode: ErrorCode, message: string } | { errorCode: ErrorCode, message: string } | { errorCode: ErrorCode, message: string } | { errorCode: ErrorCode, message: string } | { id: string, state: string, items: number, transactionId?: string | null, shipping: number, total: number, metadata?: any | null } | { errorCode: ErrorCode, message: string } | { errorCode: ErrorCode, message: string } | { errorCode: ErrorCode, message: string } };
 
 export type SettleRefundMutationVariables = Exact<{
   input: SettleRefundInput;

+ 62 - 32
packages/core/e2e/order.e2e-spec.ts

@@ -46,6 +46,8 @@ import {
     OrderLineInput,
     PaymentFragment,
     RefundFragment,
+    RefundOrderDocument,
+    SettlePaymentDocument,
     SortOrder,
     StockMovementType,
     TransitFulfillmentDocument,
@@ -112,7 +114,7 @@ describe('Orders resolver', () => {
     const fulfillmentGuard: ErrorResultGuard<FulfillmentFragment> = createErrorResultGuard(
         input => !!input.method,
     );
-    const refundGuard: ErrorResultGuard<RefundFragment> = createErrorResultGuard(input => !!input.items);
+    const refundGuard: ErrorResultGuard<RefundFragment> = createErrorResultGuard(input => !!input.total);
 
     beforeAll(async () => {
         await server.init({
@@ -1574,8 +1576,6 @@ describe('Orders resolver', () => {
 
     describe('refunds', () => {
         let orderId: string;
-        let product: Codegen.GetProductWithVariantsQuery['product'];
-        let productVariantId: string;
         let paymentId: string;
         let refundId: string;
 
@@ -1587,8 +1587,6 @@ describe('Orders resolver', () => {
                 password,
             );
             orderId = result.orderId;
-            product = result.product;
-            productVariantId = result.productVariantId;
         });
 
         it('cannot refund from PaymentAuthorized state', async () => {
@@ -1742,33 +1740,6 @@ describe('Orders resolver', () => {
             expect(settleRefund.transactionId).toBe('aaabbb');
         });
 
-        // TODO: I think we should remove this restriction
-        it.skip('returns error result if attempting to refund the same item more than once', async () => {
-            const { order } = await adminClient.query<Codegen.GetOrderQuery, Codegen.GetOrderQueryVariables>(
-                GET_ORDER,
-                {
-                    id: orderId,
-                },
-            );
-            const { refundOrder } = await adminClient.query<
-                Codegen.RefundOrderMutation,
-                Codegen.RefundOrderMutationVariables
-            >(REFUND_ORDER, {
-                input: {
-                    lines: order!.lines.map(l => ({ orderLineId: l.id, quantity: l.quantity })),
-                    shipping: order!.shipping,
-                    adjustment: 0,
-                    paymentId,
-                },
-            });
-            refundGuard.assertErrorResult(refundOrder);
-
-            expect(refundOrder.message).toBe(
-                'The specified quantity is greater than the available OrderItems',
-            );
-            expect(refundOrder.errorCode).toBe(ErrorCode.QUANTITY_TOO_GREAT_ERROR);
-        });
-
         it('order history contains expected entries', async () => {
             const { order } = await adminClient.query<
                 Codegen.GetOrderHistoryQuery,
@@ -1982,6 +1953,65 @@ describe('Orders resolver', () => {
         });
     });
 
+    describe('refund by amount', () => {
+        let orderId: string;
+        let paymentId: string;
+        let refundId: string;
+
+        beforeAll(async () => {
+            const result = await createTestOrder(
+                adminClient,
+                shopClient,
+                customers[0].emailAddress,
+                password,
+            );
+            orderId = result.orderId;
+            await proceedToArrangingPayment(shopClient);
+            const order = await addPaymentToOrder(shopClient, singleStageRefundablePaymentMethod);
+            orderGuard.assertSuccess(order);
+            paymentId = order.payments![0].id;
+        });
+
+        it('return RefundAmountError if amount too large', async () => {
+            const { refundOrder } = await adminClient.query(RefundOrderDocument, {
+                input: {
+                    lines: [],
+                    shipping: 0,
+                    adjustment: 0,
+                    amount: 999999,
+                    paymentId,
+                },
+            });
+            refundGuard.assertErrorResult(refundOrder);
+
+            expect(refundOrder.message).toBe(
+                'The amount specified exceeds the refundable amount for this payment',
+            );
+            expect(refundOrder.errorCode).toBe(ErrorCode.REFUND_AMOUNT_ERROR);
+        });
+
+        it('creates a partial refund for the given amount', async () => {
+            const { order } = await adminClient.query(GetOrderDocument, {
+                id: orderId,
+            });
+
+            const refundAmount = order!.totalWithTax - 500;
+
+            const { refundOrder } = await adminClient.query(RefundOrderDocument, {
+                input: {
+                    lines: [],
+                    shipping: 0,
+                    adjustment: 0,
+                    amount: refundAmount,
+                    paymentId,
+                },
+            });
+            refundGuard.assertSuccess(refundOrder);
+
+            expect(refundOrder.total).toBe(refundAmount);
+        });
+    });
+
     describe('order notes', () => {
         let orderId: string;
         let firstNoteId: string;

+ 15 - 0
packages/core/src/api/schema/admin-api/order.api.graphql

@@ -104,6 +104,13 @@ input RefundOrderInput {
     lines: [OrderLineInput!]!
     shipping: Money!
     adjustment: Money!
+    """
+    If an amount is specified, this value will be used to create a Refund rather than calculating the
+    amount automatically. This was added in v2.2 and will be the preferred way to specify the refund
+    amount in the future. The `lines`, `shipping` and `adjustment` fields will likely be removed in a future
+    version.
+    """
+    amount: Money
     paymentId: ID!
     reason: String
 }
@@ -303,6 +310,13 @@ type QuantityTooGreatError implements ErrorResult {
     message: String!
 }
 
+"Returned if `amount` is greater than the maximum un-refunded amount of the Payment"
+type RefundAmountError implements ErrorResult {
+    errorCode: ErrorCode!
+    message: String!
+    maximumRefundable: Int!
+}
+
 "Returned when there is an error in transitioning the Refund state"
 type RefundStateTransitionError implements ErrorResult {
     errorCode: ErrorCode!
@@ -401,6 +415,7 @@ union RefundOrderResult =
     | RefundOrderStateError
     | AlreadyRefundedError
     | RefundStateTransitionError
+    | RefundAmountError
 union SettleRefundResult = Refund | RefundStateTransitionError
 union TransitionFulfillmentToStateResult = Fulfillment | FulfillmentStateTransitionError
 union TransitionPaymentToStateResult = Payment | PaymentStateTransitionError

+ 14 - 1
packages/core/src/common/error/generated-graphql-admin-errors.ts

@@ -507,6 +507,19 @@ export class QuantityTooGreatError extends ErrorResult {
   }
 }
 
+export class RefundAmountError extends ErrorResult {
+  readonly __typename = 'RefundAmountError';
+  readonly errorCode = 'REFUND_AMOUNT_ERROR' as any;
+  readonly message = 'REFUND_AMOUNT_ERROR';
+  readonly maximumRefundable: Scalars['Int'];
+  constructor(
+    input: { maximumRefundable: Scalars['Int'] }
+  ) {
+    super();
+    this.maximumRefundable = input.maximumRefundable
+  }
+}
+
 export class RefundOrderStateError extends ErrorResult {
   readonly __typename = 'RefundOrderStateError';
   readonly errorCode = 'REFUND_ORDER_STATE_ERROR' as any;
@@ -562,7 +575,7 @@ export class SettlePaymentError extends ErrorResult {
 }
 
 
-const errorTypeNames = new Set<string>(['AlreadyRefundedError', 'CancelActiveOrderError', 'CancelPaymentError', 'ChannelDefaultLanguageError', 'CouponCodeExpiredError', 'CouponCodeInvalidError', 'CouponCodeLimitError', 'CreateFulfillmentError', 'EmailAddressConflictError', 'EmptyOrderLineSelectionError', 'FacetInUseError', 'FulfillmentStateTransitionError', 'GuestCheckoutError', 'IneligibleShippingMethodError', 'InsufficientStockError', 'InsufficientStockOnHandError', 'InvalidCredentialsError', 'InvalidFulfillmentHandlerError', 'ItemsAlreadyFulfilledError', 'LanguageNotAvailableError', 'ManualPaymentStateError', 'MimeTypeError', 'MissingConditionsError', 'MultipleOrderError', 'NativeAuthStrategyError', 'NegativeQuantityError', 'NoActiveOrderError', 'NoChangesSpecifiedError', 'NothingToRefundError', 'OrderLimitError', 'OrderModificationError', 'OrderModificationStateError', 'OrderStateTransitionError', 'PaymentMethodMissingError', 'PaymentOrderMismatchError', 'PaymentStateTransitionError', 'ProductOptionInUseError', 'QuantityTooGreatError', 'RefundOrderStateError', 'RefundPaymentIdMissingError', 'RefundStateTransitionError', 'SettlePaymentError']);
+const errorTypeNames = new Set<string>(['AlreadyRefundedError', 'CancelActiveOrderError', 'CancelPaymentError', 'ChannelDefaultLanguageError', 'CouponCodeExpiredError', 'CouponCodeInvalidError', 'CouponCodeLimitError', 'CreateFulfillmentError', 'EmailAddressConflictError', 'EmptyOrderLineSelectionError', 'FacetInUseError', 'FulfillmentStateTransitionError', 'GuestCheckoutError', 'IneligibleShippingMethodError', 'InsufficientStockError', 'InsufficientStockOnHandError', 'InvalidCredentialsError', 'InvalidFulfillmentHandlerError', 'ItemsAlreadyFulfilledError', 'LanguageNotAvailableError', 'ManualPaymentStateError', 'MimeTypeError', 'MissingConditionsError', 'MultipleOrderError', 'NativeAuthStrategyError', 'NegativeQuantityError', 'NoActiveOrderError', 'NoChangesSpecifiedError', 'NothingToRefundError', 'OrderLimitError', 'OrderModificationError', 'OrderModificationStateError', 'OrderStateTransitionError', 'PaymentMethodMissingError', 'PaymentOrderMismatchError', 'PaymentStateTransitionError', 'ProductOptionInUseError', 'QuantityTooGreatError', 'RefundAmountError', 'RefundOrderStateError', 'RefundPaymentIdMissingError', 'RefundStateTransitionError', 'SettlePaymentError']);
 function isGraphQLError(input: any): input is import('@vendure/common/lib/generated-types').ErrorResult {
   return input instanceof ErrorResult || errorTypeNames.has(input.__typename);
 }

+ 12 - 0
packages/core/src/entity/refund/refund.entity.ts

@@ -15,10 +15,22 @@ export class Refund extends VendureEntity {
         super(input);
     }
 
+    /**
+     * @deprecated Since v2.2, the `items` field will not be used by default. Instead, the `total` field
+     * alone will be used to determine the refund amount.
+     */
     @Money() items: number;
 
+    /**
+     * @deprecated Since v2.2, the `shipping` field will not be used by default. Instead, the `total` field
+     * alone will be used to determine the refund amount.
+     */
     @Money() shipping: number;
 
+    /**
+     * @deprecated Since v2.2, the `adjustment` field will not be used by default. Instead, the `total` field
+     * alone will be used to determine the refund amount.
+     */
     @Money() adjustment: number;
 
     @Money() total: number;

+ 1 - 0
packages/core/src/i18n/messages/en.json

@@ -94,6 +94,7 @@
     "PAYMENT_STATE_TRANSITION_ERROR": "Cannot transition Payment from \"{ fromState }\" to \"{ toState }\"",
     "PRODUCT_OPTION_IN_USE_ERROR": "Cannot remove ProductOptionGroup \"{ optionGroupCode }\" as it is used by {productVariantCount, plural, one {1 ProductVariant} other {# ProductVariants}}. Use the `force` argument to remove it anyway",
     "QUANTITY_TOO_GREAT_ERROR": "The specified quantity is greater than the available OrderItems",
+    "REFUND_AMOUNT_ERROR": "The amount specified exceeds the refundable amount for this payment",
     "REFUND_ORDER_STATE_ERROR": "Cannot refund an Order in the \"{ orderState }\" state",
     "SETTLE_PAYMENT_ERROR": "Settling the payment failed",
     "VERIFICATION_TOKEN_EXPIRED_ERROR": "Verification token has expired. Use refreshCustomerVerification to send a new token.",

+ 2 - 1
packages/core/src/service/services/order.service.ts

@@ -1387,7 +1387,8 @@ export class OrderService {
     ): Promise<ErrorResultUnion<RefundOrderResult, Refund>> {
         if (
             (!input.lines || input.lines.length === 0 || summate(input.lines, 'quantity') === 0) &&
-            input.shipping === 0
+            input.shipping === 0 &&
+            !input.amount
         ) {
             return new NothingToRefundError();
         }

+ 54 - 25
packages/core/src/service/services/payment.service.ts

@@ -8,6 +8,7 @@ import { RequestContext } from '../../api/common/request-context';
 import { InternalServerError } from '../../common/error/errors';
 import {
     PaymentStateTransitionError,
+    RefundAmountError,
     RefundStateTransitionError,
 } from '../../common/error/generated-graphql-admin-errors';
 import { IneligiblePaymentMethodError } from '../../common/error/generated-graphql-shop-errors';
@@ -283,38 +284,36 @@ export class PaymentService {
         input: RefundOrderInput,
         order: Order,
         selectedPayment: Payment,
-    ): Promise<Refund | RefundStateTransitionError> {
+    ): Promise<Refund | RefundStateTransitionError | RefundAmountError> {
         const orderWithRefunds = await this.connection.getEntityOrThrow(ctx, Order, order.id, {
             relations: ['payments', 'payments.refunds'],
         });
 
-        function paymentRefundTotal(payment: Payment): number {
-            const nonFailedRefunds = payment.refunds?.filter(refund => refund.state !== 'Failed') ?? [];
-            return summate(nonFailedRefunds, 'total');
+        if (input.amount) {
+            const paymentToRefund = orderWithRefunds.payments.find(p =>
+                idsAreEqual(p.id, selectedPayment.id),
+            );
+            if (!paymentToRefund) {
+                throw new InternalServerError('Could not find a Payment to refund');
+            }
+            const refundableAmount = paymentToRefund.amount - this.getPaymentRefundTotal(paymentToRefund);
+            if (refundableAmount < input.amount) {
+                return new RefundAmountError({ maximumRefundable: refundableAmount });
+            }
         }
 
         const refundsCreated: Refund[] = [];
         const refundablePayments = orderWithRefunds.payments.filter(p => {
-            return paymentRefundTotal(p) < p.amount;
+            return this.getPaymentRefundTotal(p) < p.amount;
         });
-        let refundOrderLinesTotal = 0;
-        const orderLines = await this.connection
-            .getRepository(ctx, OrderLine)
-            .find({ where: { id: In(input.lines.map(l => l.orderLineId)) } });
-        for (const line of input.lines) {
-            const orderLine = orderLines.find(l => idsAreEqual(l.id, line.orderLineId));
-            if (orderLine && 0 < orderLine.orderPlacedQuantity) {
-                refundOrderLinesTotal += line.quantity * orderLine.proratedUnitPriceWithTax;
-            }
-        }
         let primaryRefund: Refund | undefined;
         const refundedPaymentIds: ID[] = [];
-        const refundTotal = refundOrderLinesTotal + input.shipping + input.adjustment;
+        const { total, orderLinesTotal } = await this.getRefundAmount(ctx, input);
         const refundMax =
             orderWithRefunds.payments
-                ?.map(p => p.amount - paymentRefundTotal(p))
+                ?.map(p => p.amount - this.getPaymentRefundTotal(p))
                 .reduce((sum, amount) => sum + amount, 0) ?? 0;
-        let refundOutstanding = Math.min(refundTotal, refundMax);
+        let refundOutstanding = Math.min(total, refundMax);
         do {
             const paymentToRefund =
                 (refundedPaymentIds.length === 0 &&
@@ -323,18 +322,18 @@ export class PaymentService {
             if (!paymentToRefund) {
                 throw new InternalServerError('Could not find a Payment to refund');
             }
-            const amountNotRefunded = paymentToRefund.amount - paymentRefundTotal(paymentToRefund);
-            const total = Math.min(amountNotRefunded, refundOutstanding);
+            const amountNotRefunded = paymentToRefund.amount - this.getPaymentRefundTotal(paymentToRefund);
+            const constrainedTotal = Math.min(amountNotRefunded, refundOutstanding);
             let refund = new Refund({
                 payment: paymentToRefund,
-                total,
-                items: refundOrderLinesTotal,
+                total: constrainedTotal,
                 reason: input.reason,
-                adjustment: input.adjustment,
-                shipping: input.shipping,
                 method: selectedPayment.method,
                 state: 'Pending',
                 metadata: {},
+                items: orderLinesTotal, // deprecated
+                adjustment: input.adjustment, // deprecated
+                shipping: input.shipping, // deprecated
             });
             let paymentMethod: PaymentMethod | undefined;
             let handler: PaymentMethodHandler | undefined;
@@ -414,12 +413,42 @@ export class PaymentService {
             }
             refundsCreated.push(refund);
             refundedPaymentIds.push(paymentToRefund.id);
-            refundOutstanding = refundTotal - summate(refundsCreated, 'total');
+            refundOutstanding = total - summate(refundsCreated, 'total');
         } while (0 < refundOutstanding);
         // eslint-disable-next-line @typescript-eslint/no-non-null-assertion
         return primaryRefund;
     }
 
+    /**
+     * @description
+     * Returns the total amount of all Refunds against the given Payment.
+     */
+    private getPaymentRefundTotal(payment: Payment): number {
+        const nonFailedRefunds = payment.refunds?.filter(refund => refund.state !== 'Failed') ?? [];
+        return summate(nonFailedRefunds, 'total');
+    }
+
+    private async getRefundAmount(
+        ctx: RequestContext,
+        input: RefundOrderInput,
+    ): Promise<{ orderLinesTotal: number; total: number }> {
+        if (input.amount) {
+            return { orderLinesTotal: 0, total: input.amount };
+        }
+        let refundOrderLinesTotal = 0;
+        const orderLines = await this.connection
+            .getRepository(ctx, OrderLine)
+            .find({ where: { id: In(input.lines.map(l => l.orderLineId)) } });
+        for (const line of input.lines) {
+            const orderLine = orderLines.find(l => idsAreEqual(l.id, line.orderLineId));
+            if (orderLine && 0 < orderLine.orderPlacedQuantity) {
+                refundOrderLinesTotal += line.quantity * orderLine.proratedUnitPriceWithTax;
+            }
+        }
+        const total = refundOrderLinesTotal + input.shipping + input.adjustment;
+        return { orderLinesTotal: refundOrderLinesTotal, total };
+    }
+
     private mergePaymentMetadata(m1: PaymentMetadata, m2?: PaymentMetadata): PaymentMetadata {
         if (!m2) {
             return m1;

+ 16 - 1
packages/elasticsearch-plugin/e2e/graphql/generated-e2e-elasticsearch-plugin-types.ts

@@ -1546,6 +1546,7 @@ export enum ErrorCode {
   PAYMENT_STATE_TRANSITION_ERROR = 'PAYMENT_STATE_TRANSITION_ERROR',
   PRODUCT_OPTION_IN_USE_ERROR = 'PRODUCT_OPTION_IN_USE_ERROR',
   QUANTITY_TOO_GREAT_ERROR = 'QUANTITY_TOO_GREAT_ERROR',
+  REFUND_AMOUNT_ERROR = 'REFUND_AMOUNT_ERROR',
   REFUND_ORDER_STATE_ERROR = 'REFUND_ORDER_STATE_ERROR',
   REFUND_PAYMENT_ID_MISSING_ERROR = 'REFUND_PAYMENT_ID_MISSING_ERROR',
   REFUND_STATE_TRANSITION_ERROR = 'REFUND_STATE_TRANSITION_ERROR',
@@ -5153,6 +5154,13 @@ export type Refund = Node & {
   updatedAt: Scalars['DateTime']['output'];
 };
 
+/** Returned if `amount` is greater than the maximum un-refunded amount of the Payment */
+export type RefundAmountError = ErrorResult & {
+  errorCode: ErrorCode;
+  maximumRefundable: Scalars['Int']['output'];
+  message: Scalars['String']['output'];
+};
+
 export type RefundLine = {
   orderLine: OrderLine;
   orderLineId: Scalars['ID']['output'];
@@ -5163,13 +5171,20 @@ export type RefundLine = {
 
 export type RefundOrderInput = {
   adjustment: Scalars['Money']['input'];
+  /**
+   * If an amount is specified, this value will be used to create a Refund rather than calculating the
+   * amount automatically. This was added in v2.2 and will be the preferred way to specify the refund
+   * amount in the future. The `lines`, `shipping` and `adjustment` fields will likely be removed in a future
+   * version.
+   */
+  amount?: InputMaybe<Scalars['Money']['input']>;
   lines: Array<OrderLineInput>;
   paymentId: Scalars['ID']['input'];
   reason?: InputMaybe<Scalars['String']['input']>;
   shipping: Scalars['Money']['input'];
 };
 
-export type RefundOrderResult = AlreadyRefundedError | MultipleOrderError | NothingToRefundError | OrderStateTransitionError | PaymentOrderMismatchError | QuantityTooGreatError | Refund | RefundOrderStateError | RefundStateTransitionError;
+export type RefundOrderResult = AlreadyRefundedError | MultipleOrderError | NothingToRefundError | OrderStateTransitionError | PaymentOrderMismatchError | QuantityTooGreatError | Refund | RefundAmountError | RefundOrderStateError | RefundStateTransitionError;
 
 /** Returned if an attempting to refund an Order which is not in the expected state */
 export type RefundOrderStateError = ErrorResult & {

+ 17 - 2
packages/payments-plugin/e2e/graphql/generated-admin-types.ts

@@ -1546,6 +1546,7 @@ export enum ErrorCode {
   PAYMENT_STATE_TRANSITION_ERROR = 'PAYMENT_STATE_TRANSITION_ERROR',
   PRODUCT_OPTION_IN_USE_ERROR = 'PRODUCT_OPTION_IN_USE_ERROR',
   QUANTITY_TOO_GREAT_ERROR = 'QUANTITY_TOO_GREAT_ERROR',
+  REFUND_AMOUNT_ERROR = 'REFUND_AMOUNT_ERROR',
   REFUND_ORDER_STATE_ERROR = 'REFUND_ORDER_STATE_ERROR',
   REFUND_PAYMENT_ID_MISSING_ERROR = 'REFUND_PAYMENT_ID_MISSING_ERROR',
   REFUND_STATE_TRANSITION_ERROR = 'REFUND_STATE_TRANSITION_ERROR',
@@ -5153,6 +5154,13 @@ export type Refund = Node & {
   updatedAt: Scalars['DateTime']['output'];
 };
 
+/** Returned if `amount` is greater than the maximum un-refunded amount of the Payment */
+export type RefundAmountError = ErrorResult & {
+  errorCode: ErrorCode;
+  maximumRefundable: Scalars['Int']['output'];
+  message: Scalars['String']['output'];
+};
+
 export type RefundLine = {
   orderLine: OrderLine;
   orderLineId: Scalars['ID']['output'];
@@ -5163,13 +5171,20 @@ export type RefundLine = {
 
 export type RefundOrderInput = {
   adjustment: Scalars['Money']['input'];
+  /**
+   * If an amount is specified, this value will be used to create a Refund rather than calculating the
+   * amount automatically. This was added in v2.2 and will be the preferred way to specify the refund
+   * amount in the future. The `lines`, `shipping` and `adjustment` fields will likely be removed in a future
+   * version.
+   */
+  amount?: InputMaybe<Scalars['Money']['input']>;
   lines: Array<OrderLineInput>;
   paymentId: Scalars['ID']['input'];
   reason?: InputMaybe<Scalars['String']['input']>;
   shipping: Scalars['Money']['input'];
 };
 
-export type RefundOrderResult = AlreadyRefundedError | MultipleOrderError | NothingToRefundError | OrderStateTransitionError | PaymentOrderMismatchError | QuantityTooGreatError | Refund | RefundOrderStateError | RefundStateTransitionError;
+export type RefundOrderResult = AlreadyRefundedError | MultipleOrderError | NothingToRefundError | OrderStateTransitionError | PaymentOrderMismatchError | QuantityTooGreatError | Refund | RefundAmountError | RefundOrderStateError | RefundStateTransitionError;
 
 /** Returned if an attempting to refund an Order which is not in the expected state */
 export type RefundOrderStateError = ErrorResult & {
@@ -6322,7 +6337,7 @@ export type RefundOrderMutationVariables = Exact<{
 }>;
 
 
-export type RefundOrderMutation = { refundOrder: { errorCode: ErrorCode, message: string } | { errorCode: ErrorCode, message: string } | { errorCode: ErrorCode, message: string } | { errorCode: ErrorCode, message: string } | { errorCode: ErrorCode, message: string } | { errorCode: ErrorCode, message: string } | { id: string, state: string, items: number, transactionId?: string | null, shipping: number, total: number, metadata?: any | null } | { errorCode: ErrorCode, message: string } | { errorCode: ErrorCode, message: string } };
+export type RefundOrderMutation = { refundOrder: { errorCode: ErrorCode, message: string } | { errorCode: ErrorCode, message: string } | { errorCode: ErrorCode, message: string } | { errorCode: ErrorCode, message: string } | { errorCode: ErrorCode, message: string } | { errorCode: ErrorCode, message: string } | { id: string, state: string, items: number, transactionId?: string | null, shipping: number, total: number, metadata?: any | null } | { errorCode: ErrorCode, message: string } | { errorCode: ErrorCode, message: string } | { errorCode: ErrorCode, message: string } };
 
 export type OrderQueryVariables = Exact<{
   id: Scalars['ID']['input'];

文件差异内容过多而无法显示
+ 0 - 0
schema-admin.json


部分文件因为文件数量过多而无法显示