diff --git a/.changeset/rotten-hairs-thank.md b/.changeset/rotten-hairs-thank.md new file mode 100644 index 0000000000..d97e081ca6 --- /dev/null +++ b/.changeset/rotten-hairs-thank.md @@ -0,0 +1,5 @@ +--- +"@medusajs/medusa": patch +--- + +fix(medusa): Refund amount on returns in claim flow diff --git a/integration-tests/api/__tests__/admin/order/order.js b/integration-tests/api/__tests__/admin/order/order.js index 6ae0f664fd..83275376a5 100644 --- a/integration-tests/api/__tests__/admin/order/order.js +++ b/integration-tests/api/__tests__/admin/order/order.js @@ -1441,6 +1441,57 @@ describe("/admin/orders", () => { ) }) + it("Receives return with custom refund amount passed on receive", async () => { + const api = useApi() + + const orderId = "test-order" + const itemId = "test-item" + + const returned = await api.post( + `/admin/orders/${orderId}/return`, + { + items: [ + { + // item has a unit_price of 8000 with a 800 adjustment + item_id: itemId, + quantity: 1, + }, + ], + }, + adminReqConfig + ) + + const returnOrder = returned.data.order.returns[0] + + expect(returned.status).toEqual(200) + expect(returnOrder.refund_amount).toEqual(7200) + + const received = await api.post( + `/admin/returns/${returnOrder.id}/receive`, + { + items: [ + { + item_id: itemId, + quantity: 1, + }, + ], + refund: 0, + }, + adminReqConfig + ) + + const receivedReturn = received.data.return + + expect(received.status).toEqual(200) + expect(receivedReturn.refund_amount).toEqual(0) + + const orderRes = await api.get(`/admin/orders/${orderId}`, adminReqConfig) + + const order = orderRes.data.order + + expect(order.refunds.length).toEqual(0) + }) + it("increases inventory_quantity when return is received", async () => { const api = useApi() diff --git a/integration-tests/api/__tests__/claims/index.js b/integration-tests/api/__tests__/claims/index.js index 7c06395dce..2a39509ee3 100644 --- a/integration-tests/api/__tests__/claims/index.js +++ b/integration-tests/api/__tests__/claims/index.js @@ -121,6 +121,83 @@ describe("Claims", () => { ) }) + test("creates a refund claim + return", async () => { + await adminSeeder(dbConnection) + + const order = await createReturnableOrder(dbConnection) + const api = useApi() + + const response = await api.post( + `/admin/orders/${order.id}/claims`, + { + type: "refund", + claim_items: [ + { + item_id: "test-item", + reason: "missing_item", + quantity: 1, + }, + ], + return_shipping: { + price: 0, + }, + }, + { + headers: { + authorization: "Bearer test_token", + }, + } + ) + + const claim = response.data.order.claims[0] + const refund = response.data.order.refunds[0] + const returnOrder = response.data.order.returns[0] + + expect(response.status).toEqual(200) + expect(claim.refund_amount).toEqual(1200) + expect(refund.amount).toEqual(1200) + expect(returnOrder.refund_amount).toEqual(1200) + }) + + test("creates a refund claim + return with a custom amount", async () => { + await adminSeeder(dbConnection) + + const order = await createReturnableOrder(dbConnection) + const api = useApi() + + const response = await api.post( + `/admin/orders/${order.id}/claims`, + { + type: "refund", + refund_amount: 500, + claim_items: [ + { + item_id: "test-item", + reason: "missing_item", + quantity: 1, + }, + ], + return_shipping: { + price: 0, + }, + }, + { + headers: { + authorization: "Bearer test_token", + }, + } + ) + + const claim = response.data.order.claims[0] + const refund = response.data.order.refunds[0] + const returnOrder = response.data.order.returns[0] + + expect(response.status).toEqual(200) + expect(claim.refund_amount).toEqual(500) + expect(refund.amount).toEqual(500) + expect(returnOrder.refund_amount).toEqual(500) + }) + test("creates a replace claim", async () => { await adminSeeder(dbConnection) diff --git a/packages/medusa/src/services/__tests__/claim.js b/packages/medusa/src/services/__tests__/claim.js index 7a68b2885f..3213bf246a 100644 --- a/packages/medusa/src/services/__tests__/claim.js +++ b/packages/medusa/src/services/__tests__/claim.js @@ -1,4 +1,4 @@ -import { IdMap, MockRepository, MockManager } from "medusa-test-utils" +import { IdMap, MockManager, MockRepository } from "medusa-test-utils" import ClaimService from "../claim" import { ProductVariantInventoryServiceMock } from "../__mocks__/product-variant-inventory" @@ -14,6 +14,7 @@ const eventBusService = { const totalsService = { getCalculationContext: jest.fn(() => {}), getRefundTotal: jest.fn(() => 1000), + getLineItemRefund: jest.fn(() => 8000), } describe("ClaimService", () => { @@ -28,6 +29,7 @@ describe("ClaimService", () => { { id: "itm_1", unit_price: 8000, + shipped_quantity: 1, }, ], }, @@ -134,6 +136,7 @@ describe("ClaimService", () => { expect(returnService.create).toHaveBeenCalledWith({ order_id: "1234", claim_order_id: "claim_134", + refund_amount: 8000, shipping_method: { option_id: "opt_13", price: 0, @@ -181,7 +184,7 @@ describe("ClaimService", () => { expect(claimRepo.create).toHaveBeenCalledWith({ payment_status: "not_refunded", no_notification: true, - refund_amount: 1000, + refund_amount: 8000, type: "refund", order_id: "1234", additional_items: [ @@ -291,6 +294,206 @@ describe("ClaimService", () => { ) }) + describe("getRefundTotalForClaimLinesOnOrder", () => { + const testOrder = (items = [], swaps = [], claims = []) => ({ + id: "1234", + region_id: "order_region", + no_notification: true, + items: [ + { + id: "itm_1", + unit_price: 8000, + shipped_quantity: 1, + }, + ...items, + ], + ...swaps, + ...claims, + }) + + const claimRepo = MockRepository({}) + + const claimService = new ClaimService({ + manager: MockManager, + claimRepository: claimRepo, + totalsService, + }) + + beforeEach(async () => { + jest.clearAllMocks() + }) + + it("calculates refund total for claim with one shipped item", async () => { + const order = testOrder([ + { + id: "itm_1", + unit_price: 8000, + shipped_quantity: 1, + }, + ]) + + const refund = await claimService.getRefundTotalForClaimLinesOnOrder( + order, + [ + { + item_id: "itm_1", + quantity: 1, + }, + ] + ) + + expect(totalsService.getLineItemRefund).toHaveBeenCalledTimes(1) + expect(totalsService.getLineItemRefund).toHaveBeenCalledWith(order, { + id: "itm_1", + unit_price: 8000, + quantity: 1, + shipped_quantity: 1, + }) + + expect(refund).toEqual(8000) + }) + + it("calculates refund total for claim with one shipped + one pending item", async () => { + const order = testOrder([{ id: "itm_2", shipped_quantity: 0 }]) + + const refund = await claimService.getRefundTotalForClaimLinesOnOrder( + order, + [ + { + item_id: "itm_1", + quantity: 1, + }, + ] + ) + + expect(totalsService.getLineItemRefund).toHaveBeenCalledTimes(1) + expect(totalsService.getLineItemRefund).toHaveBeenCalledWith(order, { + id: "itm_1", + unit_price: 8000, + quantity: 1, + shipped_quantity: 1, + }) + + expect(refund).toEqual(8000) + }) + + it("calculates refund total for claim with two shipped + one pending item", async () => { + const order = testOrder([ + { id: "itm_2", shipped_quantity: 0 }, + { id: "itm_3", shipped_quantity: 1, unit_price: 5000 }, + ]) + + const refund = await claimService.getRefundTotalForClaimLinesOnOrder( + order, + [ + { + item_id: "itm_1", + quantity: 1, + }, + { item_id: "itm_3", quantity: 1 }, + ] + ) + + expect(totalsService.getLineItemRefund).toHaveBeenCalledTimes(2) + expect(totalsService.getLineItemRefund.mock.calls).toEqual([ + [ + order, + { + id: "itm_1", + unit_price: 8000, + quantity: 1, + shipped_quantity: 1, + }, + ], + [ + order, + { + id: "itm_3", + unit_price: 5000, + quantity: 1, + shipped_quantity: 1, + }, + ], + ]) + + expect(refund).toEqual(16000) + }) + + it("calculates refund total for claim on swap items", async () => { + const order = testOrder([], [[{ id: "itm_1", shipped_quantity: 1 }]]) + + const refund = await claimService.getRefundTotalForClaimLinesOnOrder( + order, + [ + { + item_id: "itm_1", + quantity: 1, + }, + ] + ) + + expect(totalsService.getLineItemRefund).toHaveBeenCalledTimes(1) + expect(totalsService.getLineItemRefund.mock.calls).toEqual([ + [ + order, + { + id: "itm_1", + unit_price: 8000, + quantity: 1, + shipped_quantity: 1, + }, + ], + ]) + + expect(refund).toEqual(8000) + }) + + it("calculates refund total for claim on claim items", async () => { + const order = testOrder([], [], [{ id: "itm_1", shipped_quantity: 1 }]) + + const refund = await claimService.getRefundTotalForClaimLinesOnOrder( + order, + [ + { + item_id: "itm_1", + quantity: 1, + }, + ] + ) + + expect(totalsService.getLineItemRefund).toHaveBeenCalledTimes(1) + expect(totalsService.getLineItemRefund.mock.calls).toEqual([ + [ + order, + { + id: "itm_1", + unit_price: 8000, + quantity: 1, + shipped_quantity: 1, + }, + ], + ]) + + expect(refund).toEqual(8000) + }) + + it("return 0 when claim lines cannot be found", async () => { + const order = testOrder([ + { id: "itm_2", shipped_quantity: 0 }, + { id: "itm_3", shipped_quantity: 1, unit_price: 5000 }, + ]) + + const refund = await claimService.getRefundTotalForClaimLinesOnOrder( + order, + [{ item_id: "itm_10", quantity: 1 }] + ) + + expect(totalsService.getLineItemRefund).toHaveBeenCalledTimes(0) + + expect(refund).toEqual(0) + }) + }) + describe("retrieve", () => { const claimRepo = MockRepository() const claimService = new ClaimService({ diff --git a/packages/medusa/src/services/claim.ts b/packages/medusa/src/services/claim.ts index 93ba081198..ed02d2fb62 100644 --- a/packages/medusa/src/services/claim.ts +++ b/packages/medusa/src/services/claim.ts @@ -8,13 +8,18 @@ import { ClaimType, FulfillmentItem, LineItem, + Order, ReturnItem, } from "../models" import { AddressRepository } from "../repositories/address" import { ClaimRepository } from "../repositories/claim" import { LineItemRepository } from "../repositories/line-item" import { ShippingMethodRepository } from "../repositories/shipping-method" -import { CreateClaimInput, UpdateClaimInput } from "../types/claim" +import { + CreateClaimInput, + CreateClaimItemInput, + UpdateClaimInput, +} from "../types/claim" import { FindConfig } from "../types/common" import { buildQuery, setMetadata } from "../utils" import ClaimItemService from "./claim-item" @@ -204,6 +209,124 @@ export default class ClaimService extends TransactionBaseService { ) } + protected async validateCreateClaimInput( + data: CreateClaimInput + ): Promise { + const lineItemServiceTx = this.lineItemService_.withTransaction( + this.manager_ + ) + + const { type, claim_items, additional_items, refund_amount } = data + + if (type !== ClaimType.REFUND && type !== ClaimType.REPLACE) { + throw new MedusaError( + MedusaError.Types.INVALID_DATA, + `Claim type must be one of "refund" or "replace".` + ) + } + + if (type === ClaimType.REPLACE && !additional_items?.length) { + throw new MedusaError( + MedusaError.Types.INVALID_DATA, + `Claims with type "replace" must have at least one additional item.` + ) + } + + if (!claim_items?.length) { + throw new MedusaError( + MedusaError.Types.INVALID_DATA, + `Claims must have at least one claim item.` + ) + } + + if (refund_amount && type !== ClaimType.REFUND) { + throw new MedusaError( + MedusaError.Types.INVALID_DATA, + `Claim has type "${type}" but must be type "refund" to have a refund_amount.` + ) + } + + const claimLineItems = await lineItemServiceTx.list( + { id: claim_items.map((c) => c.item_id) }, + { relations: ["order", "swap", "claim_order", "tax_lines"] } + ) + + for (const line of claimLineItems) { + if ( + line.order?.canceled_at || + line.swap?.canceled_at || + line.claim_order?.canceled_at + ) { + throw new MedusaError( + MedusaError.Types.INVALID_DATA, + `Cannot create a claim on a canceled item.` + ) + } + } + } + + /** + * Finds claim line items on an order and calculates the refund amount. + * There are three places too look: + * - Order items + * - Swap items + * - Claim items (from previous claims) + * Note, it will attempt to return early from each of these places to avoid having to iterate over all items every time. + * @param order - the order to find claim lines on + * @param claimItems - the claim items to match against + * @return the refund amount + */ + protected async getRefundTotalForClaimLinesOnOrder( + order: Order, + claimItems: CreateClaimItemInput[] + ) { + const claimLines = claimItems + .map((ci) => { + const predicate = (it: LineItem) => + it.shipped_quantity! > 0 && + ci.quantity <= it.shipped_quantity! && + it.id === ci.item_id + + const claimLine = order.items.find(predicate) + + if (claimLine) { + return { ...claimLine, quantity: ci.quantity } + } + + if (order.swaps?.length) { + for (const swap of order.swaps) { + const claimLine = swap.additional_items.find(predicate) + + if (claimLine) { + return { ...claimLine, quantity: ci.quantity } + } + } + } + + if (order.claims?.length) { + for (const claim of order.claims) { + const claimLine = claim.additional_items.find(predicate) + + if (claimLine) { + return { ...claimLine, quantity: ci.quantity } + } + } + } + + return null + }) + .filter(Boolean) as LineItem[] + + const refunds: number[] = [] + + for (const item of claimLines) { + const refund = await this.totalsService_.getLineItemRefund(order, item) + refunds.push(refund) + } + + return Math.round(refunds.reduce((acc, next) => acc + next, 0)) + } + /** * Creates a Claim on an Order. Claims consists of items that are claimed and * optionally items to be sent as replacement for the claimed items. The @@ -232,25 +355,7 @@ export default class ClaimService extends TransactionBaseService { ...rest } = data - const lineItemServiceTx = - this.lineItemService_.withTransaction(transactionManager) - - for (const item of claim_items) { - const line = await lineItemServiceTx.retrieve(item.item_id, { - relations: ["order", "swap", "claim_order", "tax_lines"], - }) - - if ( - line.order?.canceled_at || - line.swap?.canceled_at || - line.claim_order?.canceled_at - ) { - throw new MedusaError( - MedusaError.Types.INVALID_DATA, - `Cannot create a claim on a canceled item.` - ) - } - } + await this.validateCreateClaimInput(data) let addressId = shipping_address_id || order.shipping_address_id if (shipping_address) { @@ -262,76 +367,18 @@ export default class ClaimService extends TransactionBaseService { addressId = saved.id } - if (type !== ClaimType.REFUND && type !== ClaimType.REPLACE) { - throw new MedusaError( - MedusaError.Types.INVALID_DATA, - `Claim type must be one of "refund" or "replace".` - ) - } - - if (type === ClaimType.REPLACE && !additional_items?.length) { - throw new MedusaError( - MedusaError.Types.INVALID_DATA, - `Claims with type "replace" must have at least one additional item.` - ) - } - - if (!claim_items?.length) { - throw new MedusaError( - MedusaError.Types.INVALID_DATA, - `Claims must have at least one claim item.` - ) - } - - if (refund_amount && type !== ClaimType.REFUND) { - throw new MedusaError( - MedusaError.Types.INVALID_DATA, - `Claim has type "${type}" but must be type "refund" to have a refund_amount.` - ) - } - let toRefund = refund_amount if (type === ClaimType.REFUND && typeof refund_amount === "undefined") { - const lines = claim_items.map((ci) => { - const allOrderItems = order.items - - if (order.swaps?.length) { - for (const swap of order.swaps) { - swap.additional_items.forEach((it) => { - if ( - it.shipped_quantity || - it.shipped_quantity === it.fulfilled_quantity - ) { - allOrderItems.push(it) - } - }) - } - } - - if (order.claims?.length) { - for (const claim of order.claims) { - claim.additional_items.forEach((it) => { - if ( - it.shipped_quantity || - it.shipped_quantity === it.fulfilled_quantity - ) { - allOrderItems.push(it) - } - }) - } - } - - const orderItem = allOrderItems.find((oi) => oi.id === ci.item_id) - return { - ...orderItem, - quantity: ci.quantity, - } - }) - toRefund = await this.totalsService_.getRefundTotal( + // In case no refund amount is passed, we calculate it based on the claim items on the order + toRefund = await this.getRefundTotalForClaimLinesOnOrder( order, - lines as LineItem[] + claim_items ) } + + const lineItemServiceTx = + this.lineItemService_.withTransaction(transactionManager) + let newItems: LineItem[] = [] if (isDefined(additional_items)) { @@ -425,6 +472,7 @@ export default class ClaimService extends TransactionBaseService { if (return_shipping) { await this.returnService_.withTransaction(transactionManager).create({ + refund_amount: toRefund, order_id: order.id, claim_order_id: result.id, items: claim_items.map( diff --git a/packages/medusa/src/services/order.ts b/packages/medusa/src/services/order.ts index 750c8a83b8..5bea5d912e 100644 --- a/packages/medusa/src/services/order.ts +++ b/packages/medusa/src/services/order.ts @@ -1883,7 +1883,7 @@ class OrderService extends TransactionBaseService { ) } - const refundAmount = customRefundAmount || receivedReturn.refund_amount + const refundAmount = customRefundAmount ?? receivedReturn.refund_amount const orderRepo = manager.getCustomRepository(this.orderRepository_) @@ -1907,10 +1907,10 @@ class OrderService extends TransactionBaseService { } } - if (receivedReturn.refund_amount > 0) { + if (refundAmount > 0) { const refund = await this.paymentProviderService_ .withTransaction(manager) - .refundPayment(order.payments, receivedReturn.refund_amount, "return") + .refundPayment(order.payments, refundAmount, "return") order.refunds = [...(order.refunds || []), refund] } diff --git a/packages/medusa/src/services/return.ts b/packages/medusa/src/services/return.ts index 4e1c48ae3f..bda67554ef 100644 --- a/packages/medusa/src/services/return.ts +++ b/packages/medusa/src/services/return.ts @@ -19,9 +19,9 @@ import { buildQuery, setMetadata } from "../utils" import { FulfillmentProviderService, - ProductVariantInventoryService, LineItemService, OrderService, + ProductVariantInventoryService, ReturnReasonService, ShippingOptionService, TaxProviderService, @@ -653,7 +653,7 @@ class ReturnService extends TransactionBaseService { returnStatus = ReturnStatus.REQUIRES_ACTION } - const totalRefundableAmount = refundAmount || returnObj.refund_amount + const totalRefundableAmount = refundAmount ?? returnObj.refund_amount const now = new Date() const updateObj = { diff --git a/packages/medusa/src/services/swap.ts b/packages/medusa/src/services/swap.ts index 444ea68768..54247ffef3 100644 --- a/packages/medusa/src/services/swap.ts +++ b/packages/medusa/src/services/swap.ts @@ -1,12 +1,28 @@ import { isDefined, MedusaError } from "medusa-core-utils" import { EntityManager, In } from "typeorm" -import { buildQuery, setMetadata, validateId } from "../utils" import { TransactionBaseService } from "../interfaces" +import { buildQuery, setMetadata, validateId } from "../utils" -import LineItemAdjustmentService from "./line-item-adjustment" -import { FindConfig, Selector, WithRequiredProperty } from "../types/common" +import { + Cart, + CartType, + FulfillmentItem, + FulfillmentStatus, + LineItem, + Order, + PaymentSessionStatus, + PaymentStatus, + ReturnItem, + ReturnStatus, + Swap, + SwapFulfillmentStatus, + SwapPaymentStatus, +} from "../models" import { SwapRepository } from "../repositories/swap" +import { FindConfig, Selector, WithRequiredProperty } from "../types/common" +import { CreateShipmentConfig } from "../types/fulfillment" +import { OrdersReturnItem } from "../types/orders" import CartService from "./cart" import { CustomShippingOptionService, @@ -20,21 +36,7 @@ import { ShippingOptionService, TotalsService, } from "./index" -import { - Cart, - CartType, - FulfillmentItem, - LineItem, - Order, - PaymentSessionStatus, - ReturnItem, - ReturnStatus, - Swap, - SwapFulfillmentStatus, - SwapPaymentStatus, -} from "../models" -import { CreateShipmentConfig } from "../types/fulfillment" -import { OrdersReturnItem } from "../types/orders" +import LineItemAdjustmentService from "./line-item-adjustment" type InjectedProps = { manager: EntityManager @@ -325,13 +327,17 @@ class SwapService extends TransactionBaseService { ): Promise { const { no_notification, ...rest } = custom return await this.atomicPhase_(async (manager) => { - if ( - order.fulfillment_status === "not_fulfilled" || - order.payment_status !== "captured" - ) { + if (order.payment_status !== PaymentStatus.CAPTURED) { throw new MedusaError( MedusaError.Types.NOT_ALLOWED, - "Order cannot be swapped" + "Cannot swap an order that has not been captured" + ) + } + + if (order.fulfillment_status === FulfillmentStatus.NOT_FULFILLED) { + throw new MedusaError( + MedusaError.Types.NOT_ALLOWED, + "Cannot swap an order that has not been fulfilled" ) }