fix(medusa): Refund amount on returns in claim flow (#3237)

This commit is contained in:
Oliver Windall Juhl
2023-02-14 12:47:06 +01:00
committed by GitHub
parent d48606d5dd
commit 968eb8fc6b
8 changed files with 505 additions and 115 deletions

View File

@@ -0,0 +1,5 @@
---
"@medusajs/medusa": patch
---
fix(medusa): Refund amount on returns in claim flow

View File

@@ -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()

View File

@@ -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)

View File

@@ -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({

View File

@@ -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<void> {
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(

View File

@@ -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]
}

View File

@@ -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 = {

View File

@@ -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<Swap | never> {
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"
)
}