feat(medusa): Swaps on swaps (#229)

Co-authored-by: Sebastian Rindom <skrindom@gmail.com>
This commit is contained in:
Oliver Windall Juhl
2021-04-20 10:55:15 +02:00
committed by GitHub
parent 2f3e3fde80
commit f8f1f57fa1
29 changed files with 1578 additions and 3276 deletions

View File

@@ -7,6 +7,7 @@ const { useApi } = require("../../../helpers/use-api");
const { initDb } = require("../../../helpers/use-db");
const orderSeeder = require("../../helpers/order-seeder");
const swapSeeder = require("../../helpers/swap-seeder");
const adminSeeder = require("../../helpers/admin-seeder");
jest.setTimeout(30000);
@@ -706,4 +707,286 @@ describe("/admin/orders", () => {
]);
});
});
describe("POST /admin/orders/:id/swaps", () => {
beforeEach(async () => {
try {
await adminSeeder(dbConnection);
await orderSeeder(dbConnection);
await swapSeeder(dbConnection);
} catch (err) {
console.log(err);
throw err;
}
});
afterEach(async () => {
const manager = dbConnection.manager;
await manager.query(`DELETE FROM "fulfillment_item"`);
await manager.query(`DELETE FROM "fulfillment"`);
await manager.query(`DELETE FROM "return_item"`);
await manager.query(`DELETE FROM "return_reason"`);
await manager.query(`DELETE FROM "return"`);
await manager.query(`DELETE FROM "claim_image"`);
await manager.query(`DELETE FROM "claim_tag"`);
await manager.query(`DELETE FROM "claim_item"`);
await manager.query(`DELETE FROM "shipping_method"`);
await manager.query(`DELETE FROM "line_item"`);
await manager.query(`DELETE FROM "payment"`);
await manager.query(`DELETE FROM "swap"`);
await manager.query(`DELETE FROM "cart"`);
await manager.query(`DELETE FROM "claim_order"`);
await manager.query(`DELETE FROM "money_amount"`);
await manager.query(`DELETE FROM "product_variant"`);
await manager.query(`DELETE FROM "product"`);
await manager.query(`DELETE FROM "shipping_option"`);
await manager.query(`DELETE FROM "discount"`);
await manager.query(`DELETE FROM "refund"`);
await manager.query(`DELETE FROM "order"`);
await manager.query(`DELETE FROM "customer"`);
await manager.query(
`UPDATE "country" SET region_id=NULL WHERE iso_2 = 'us'`
);
await manager.query(`DELETE FROM "region"`);
await manager.query(`DELETE FROM "user"`);
});
it("creates a swap", async () => {
const api = useApi();
const response = await api.post(
"/admin/orders/test-order/swaps",
{
return_items: [
{
item_id: "test-item",
quantity: 1,
},
],
additional_items: [{ variant_id: "test-variant-2", quantity: 1 }],
},
{
headers: {
authorization: "Bearer test_token",
},
}
);
expect(response.status).toEqual(200);
});
it("creates a swap and a return", async () => {
const api = useApi();
const returnedOrderFirst = await api.post(
"/admin/orders/order-with-swap/return",
{
items: [
{
item_id: "test-item-many",
quantity: 2,
},
],
receive_now: true,
},
{
headers: {
authorization: "Bearer test_token",
},
}
);
expect(returnedOrderFirst.status).toEqual(200);
const returnedOrderSecond = await api.post(
"/admin/orders/order-with-swap/return",
{
items: [
{
item_id: "test-item-many",
quantity: 1,
},
],
receive_now: true,
},
{
headers: {
authorization: "Bearer test_token",
},
}
);
expect(returnedOrderSecond.status).toEqual(200);
expect(returnedOrderSecond.data.order.items[1].returned_quantity).toBe(3);
});
it("creates a swap and receives the items", async () => {
const api = useApi();
const createdSwapOrder = await api.post(
"/admin/orders/test-order/swaps",
{
return_items: [
{
item_id: "test-item",
quantity: 1,
},
],
additional_items: [{ variant_id: "test-variant-2", quantity: 1 }],
},
{
headers: {
authorization: "Bearer test_token",
},
}
);
expect(createdSwapOrder.status).toEqual(200);
const swap = createdSwapOrder.data.order.swaps[0];
const receivedSwap = await api.post(
`/admin/returns/${swap.return_order.id}/receive`,
{
items: [
{
item_id: "test-item",
quantity: 1,
},
],
},
{
headers: {
authorization: "Bearer test_token",
},
}
);
expect(receivedSwap.status).toEqual(200);
expect(receivedSwap.data.return.status).toBe("received");
});
it("creates a swap on a swap", async () => {
const api = useApi();
const swapOnSwap = await api.post(
"/admin/orders/order-with-swap/swaps",
{
return_items: [
{
item_id: "test-item-swapped",
quantity: 1,
},
],
additional_items: [{ variant_id: "test-variant", quantity: 1 }],
},
{
headers: {
authorization: "Bearer test_token",
},
}
);
expect(swapOnSwap.status).toEqual(200);
});
it("receives a swap on swap", async () => {
const api = useApi();
const received = await api.post(
`/admin/returns/return-on-swap/receive`,
{
items: [
{
item_id: "test-item-swapped",
quantity: 1,
},
],
},
{
headers: {
authorization: "Bearer test_token",
},
}
);
expect(received.status).toEqual(200);
});
it("creates a return on a swap", async () => {
const api = useApi();
const returnOnSwap = await api.post(
"/admin/orders/order-with-swap/return",
{
items: [
{
item_id: "test-item-swapped",
quantity: 1,
},
],
},
{
headers: {
authorization: "Bearer test_token",
},
}
);
expect(returnOnSwap.status).toEqual(200);
});
it("creates a return on an order", async () => {
const api = useApi();
const returnOnOrder = await api.post(
"/admin/orders/test-order/return",
{
items: [
{
item_id: "test-item",
quantity: 1,
},
],
},
{
headers: {
authorization: "Bearer test_token",
},
}
);
expect(returnOnOrder.status).toEqual(200);
const captured = await api.post(
"/admin/orders/test-order/capture",
{},
{
headers: {
authorization: "Bearer test_token",
},
}
);
const returnId = returnOnOrder.data.order.returns[0].id;
const received = await api.post(
`/admin/returns/${returnId}/receive`,
{
items: [
{
item_id: "test-item",
quantity: 1,
},
],
},
{
headers: {
authorization: "Bearer test_token",
},
}
);
expect(received.status).toEqual(200);
});
});
});

View File

@@ -10,6 +10,7 @@ const {
ProductVariant,
Region,
Order,
Swap,
} = require("@medusajs/medusa");
module.exports = async (connection, data = {}) => {
@@ -39,6 +40,26 @@ module.exports = async (connection, data = {}) => {
],
});
await manager.insert(ProductVariant, {
id: "test-variant-2",
title: "Swap product",
product_id: "test-product",
inventory_quantity: 1,
options: [
{
option_id: "test-option",
value: "Large",
},
],
});
const ma2 = manager.create(MoneyAmount, {
variant_id: "test-variant-2",
currency_code: "usd",
amount: 8000,
});
await manager.save(ma2);
const ma = manager.create(MoneyAmount, {
variant_id: "test-variant",
currency_code: "usd",
@@ -77,6 +98,8 @@ module.exports = async (connection, data = {}) => {
id: "test-order",
customer_id: "test-customer",
email: "test@email.com",
payment_status: "captured",
fulfillment_status: "fulfilled",
billing_address: {
id: "test-billing-address",
first_name: "lebron",
@@ -115,27 +138,31 @@ module.exports = async (connection, data = {}) => {
amount: 10000,
currency_code: "usd",
amount_refunded: 0,
provider_id: "test",
provider_id: "test-pay",
data: {},
},
],
items: [
{
id: "test-item",
fulfilled_quantity: 1,
title: "Line Item",
description: "Line Item Desc",
thumbnail: "https://test.js/1234",
unit_price: 8000,
quantity: 1,
variant_id: "test-variant",
},
],
items: [],
...data,
});
await manager.save(order);
const li = manager.create(LineItem, {
id: "test-item",
fulfilled_quantity: 1,
returned_quantity: 0,
title: "Line Item",
description: "Line Item Desc",
thumbnail: "https://test.js/1234",
unit_price: 8000,
quantity: 1,
variant_id: "test-variant",
order_id: "test-order",
});
await manager.save(li);
await manager.insert(ShippingMethod, {
id: "test-method",
order_id: "test-order",

View File

@@ -0,0 +1,159 @@
const {
ShippingProfile,
Customer,
MoneyAmount,
LineItem,
Country,
ShippingOption,
ShippingMethod,
Product,
ProductVariant,
Region,
Order,
Swap,
Return,
} = require("@medusajs/medusa");
module.exports = async (connection, data = {}) => {
const manager = connection.manager;
let orderWithSwap = manager.create(Order, {
id: "order-with-swap",
customer_id: "test-customer",
email: "test@email.com",
payment_status: "captured",
fulfillment_status: "fulfilled",
billing_address: {
id: "test-billing-address",
first_name: "lebron",
},
shipping_address: {
id: "test-shipping-address",
first_name: "lebron",
country_code: "us",
},
region_id: "test-region",
currency_code: "usd",
tax_rate: 0,
discounts: [],
payments: [
{
id: "test-payment",
amount: 10000,
currency_code: "usd",
amount_refunded: 0,
provider_id: "test",
data: {},
},
],
items: [],
...data,
});
orderWithSwap = await manager.save(orderWithSwap);
const li = manager.create(LineItem, {
id: "test-item-2",
fulfilled_quantity: 1,
title: "Line Item",
description: "Line Item Desc",
thumbnail: "https://test.js/1234",
unit_price: 8000,
quantity: 1,
variant_id: "test-variant",
order_id: orderWithSwap.id,
});
await manager.save(li);
const li2 = manager.create(LineItem, {
id: "test-item-many",
fulfilled_quantity: 4,
title: "Line Item",
description: "Line Item Desc",
thumbnail: "https://test.js/1234",
unit_price: 8000,
quantity: 4,
variant_id: "test-variant",
order_id: orderWithSwap.id,
});
await manager.save(li2);
const swap = manager.create(Swap, {
id: "test-swap",
order_id: "order-with-swap",
payment_status: "captured",
fulfillment_status: "fulfilled",
payment: {
id: "test-payment-swap",
amount: 10000,
currency_code: "usd",
amount_refunded: 0,
provider_id: "test",
data: {},
},
additional_items: [
{
id: "test-item-swapped",
fulfilled_quantity: 1,
title: "Line Item",
description: "Line Item Desc",
thumbnail: "https://test.js/1234",
unit_price: 8000,
quantity: 1,
variant_id: "test-variant-2",
},
],
});
await manager.save(swap);
const swapOnSwap = manager.create(Swap, {
id: "swap-on-swap",
order_id: "order-with-swap",
payment_status: "captured",
fulfillment_status: "fulfilled",
return_order: {
id: "return-on-swap",
refund_amount: 9000,
items: [
{
return_id: "return-on-swap",
item_id: "test-item-swapped",
quantity: 1,
},
],
},
payment: {
id: "test-payment-swap-on-swap",
amount: 10000,
currency_code: "usd",
amount_refunded: 0,
provider_id: "test",
data: {},
},
additional_items: [
{
id: "test-item-swap-on-swap",
fulfilled_quantity: 1,
title: "Line Item",
description: "Line Item Desc",
thumbnail: "https://test.js/1234",
unit_price: 8000,
quantity: 1,
variant_id: "test-variant",
},
],
});
await manager.save(swapOnSwap);
await manager.insert(ShippingMethod, {
id: "test-method-swap-order",
shipping_option_id: "test-option",
order_id: "order-with-swap",
price: 1000,
data: {},
});
};

View File

@@ -8,15 +8,15 @@
"build": "babel src -d dist --extensions \".ts,.js\""
},
"dependencies": {
"@medusajs/medusa": "1.1.13-dev-1615987548667",
"medusa-interfaces": "1.1.3-dev-1615987548667",
"@medusajs/medusa": "1.1.19-dev-1618904018564",
"medusa-interfaces": "1.1.7-dev-1618904018564",
"typeorm": "^0.2.31"
},
"devDependencies": {
"@babel/cli": "^7.12.10",
"@babel/core": "^7.12.10",
"@babel/node": "^7.12.10",
"babel-preset-medusa-package": "1.1.0-dev-1615987548667",
"babel-preset-medusa-package": "1.1.0-dev-1618904018564",
"jest": "^26.6.3"
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -349,7 +349,11 @@ class BrightpearlService extends BaseService {
const region = fromOrder.region
const client = await this.getClient()
const authData = await this.getAuthData()
const orderId = fromOrder.metadata.brightpearl_sales_order_id
const orderIds = this.gatherOrders(fromOrder)
const orderId = orderIds[0]
const parentRows = await this.gatherRowsFromOrderIds(orderIds)
if (orderId) {
const parentSo = await client.orders.retrieve(orderId)
const order = {
@@ -362,7 +366,7 @@ class BrightpearlService extends BaseService {
delivery: parentSo.delivery,
parentId: orderId,
rows: fromReturn.items.map((i) => {
const parentRow = parentSo.rows.find((row) => {
const parentRow = parentRows.find((row) => {
return row.externalRef === i.item_id
})
return {
@@ -509,7 +513,9 @@ class BrightpearlService extends BaseService {
const order = await client.orders.retrieve(salesOrderId)
await client.warehouses
.createReservation(order, this.options.warehouse)
.catch(() => {})
.catch((err) => {
console.log("Failed to allocate for order:", salesOrderId)
})
return salesOrderId
})
.then((salesOrderId) => {
@@ -610,7 +616,11 @@ class BrightpearlService extends BaseService {
return client.orders.create(order).then(async (salesOrderId) => {
const order = await client.orders.retrieve(salesOrderId)
await client.warehouses.createReservation(order, this.options.warehouse)
await client.warehouses
.createReservation(order, this.options.warehouse)
.catch((err) => {
console.log("Failed to allocate for order:", salesOrderId)
})
const total = order.rows.reduce((acc, next) => {
return acc + parseFloat(next.net) + parseFloat(next.tax)
@@ -701,11 +711,43 @@ class BrightpearlService extends BaseService {
}
}
gatherOrders(fromOrder) {
const ids = []
if (fromOrder.metadata && fromOrder.metadata.brightpearl_sales_order_id) {
ids.push(fromOrder.metadata.brightpearl_sales_order_id)
}
if (fromOrder.swaps) {
for (const s of fromOrder.swaps) {
if (s.metadata && s.metadata.brightpearl_sales_order_id) {
ids.push(s.metadata.brightpearl_sales_order_id)
}
}
}
return ids
}
async gatherRowsFromOrderIds(ids) {
const client = await this.getClient()
const orders = await Promise.all(ids.map((i) => client.orders.retrieve(i)))
let rows = []
for (const o of orders) {
rows = rows.concat(o.rows)
}
return rows
}
async createSwapCredit(fromOrder, fromSwap) {
const region = fromOrder.region
const client = await this.getClient()
const authData = await this.getAuthData()
const orderId = fromOrder.metadata.brightpearl_sales_order_id
const orderIds = this.gatherOrders(fromOrder)
const orderId = orderIds[0]
const parentRows = await this.gatherRowsFromOrderIds(orderIds)
const sIndex = fromOrder.swaps.findIndex((s) => fromSwap.id === s.id)
if (orderId) {
@@ -720,7 +762,7 @@ class BrightpearlService extends BaseService {
delivery: parentSo.delivery,
parentId: orderId,
rows: fromSwap.return_order.items.map((i) => {
const parentRow = parentSo.rows.find((row) => {
const parentRow = parentRows.find((row) => {
return row.externalRef === i.item_id
})
return {
@@ -984,7 +1026,11 @@ class BrightpearlService extends BaseService {
.create(order)
.then(async (salesOrderId) => {
const order = await client.orders.retrieve(salesOrderId)
await client.warehouses.createReservation(order, this.options.warehouse)
await client.warehouses
.createReservation(order, this.options.warehouse)
.catch((err) => {
console.log("Failed to allocate for order:", salesOrderId)
})
const total = order.rows.reduce((acc, next) => {
return acc + parseFloat(next.net) + parseFloat(next.tax)

View File

@@ -39,7 +39,7 @@ class OrderSubscriber {
"swap.payment_completed",
this.registerSwapPayment
)
eventBusService.subscribe("order.swap_received", this.registerSwap)
eventBusService.subscribe("swap.received", this.registerSwap)
}
sendToBrightpearl = (data) => {
@@ -55,13 +55,13 @@ class OrderSubscriber {
}
registerSwap = async (data) => {
const { id, swap_id } = data
const { id } = data
if (!id && !swap_id) {
if (!id) {
return
}
const fromSwap = await this.swapService_.retrieve(swap_id, {
const fromSwap = await this.swapService_.retrieve(id, {
relations: [
"order",
"order.payments",
@@ -136,7 +136,7 @@ class OrderSubscriber {
const { id, return_id } = data
const order = await this.orderService_.retrieve(id, {
relations: ["region", "payments"],
relations: ["region", "swaps", "payments"],
})
const fromReturn = await this.returnService_.retrieve(return_id, {

File diff suppressed because it is too large Load Diff

View File

@@ -18,9 +18,7 @@ class OrderSubscriber {
this.fulfillmentService_ = fulfillmentService
// Swaps
// order.swap_received <--- Will be deprecated
// Swaps
// swap.created
// swap.received
// swap.shipment_created
@@ -28,7 +26,6 @@ class OrderSubscriber {
// swap.payment_captured
// swap.refund_processed
eventBusService.subscribe(
"order.shipment_created",
async ({ id, fulfillment_id }) => {
@@ -176,12 +173,19 @@ class OrderSubscriber {
})
}
let merged = [...order.items]
// merge items from order with items from order swaps
if (order.swaps && order.swaps.length) {
for (const s of order.swaps) {
merged = [...merged, ...s.additional_items]
}
}
const toBuildFrom = {
...order,
shipping_methods: shipping,
items: ret.items.map((i) =>
order.items.find((l) => l.id === i.item_id)
),
items: ret.items.map((i) => merged.find((l) => l.id === i.item_id)),
}
const orderData = await segmentService.buildOrder(toBuildFrom)

View File

@@ -107,7 +107,7 @@ class SendGridService extends NotificationService {
return this.claimShipmentCreatedData(eventData, attachmentGenerator)
case "order.items_returned":
return this.itemsReturnedData(eventData, attachmentGenerator)
case "order.swap_received":
case "swap.received":
return this.swapReceivedData(eventData, attachmentGenerator)
case "swap.created":
return this.swapCreatedData(eventData, attachmentGenerator)
@@ -147,8 +147,8 @@ class SendGridService extends NotificationService {
return map.claim_shipment_created_template
case "order.items_returned":
return map.order_items_returned_template
case "order.swap_received":
return map.order_swap_received_template
case "swap.received":
return map.swap_received_template
case "swap.created":
return map.swap_created_template
case "gift_card.created":
@@ -184,8 +184,8 @@ class SendGridService extends NotificationService {
return this.options_.claim_shipment_created_template
case "order.items_returned":
return this.options_.order_items_returned_template
case "order.swap_received":
return this.options_.order_swap_received_template
case "swap.received":
return this.options_.swap_received_template
case "swap.created":
return this.options_.swap_created_template
case "gift_card.created":
@@ -493,12 +493,28 @@ class SendGridService extends NotificationService {
// Fetch the order
const order = await this.orderService_.retrieve(id, {
select: ["total"],
relations: ["items", "discounts", "shipping_address", "returns"],
relations: [
"items",
"discounts",
"shipping_address",
"returns",
"swaps",
"swaps.additional_items",
],
})
let merged = [...order.items]
// merge items from order with items from order swaps
if (order.swaps && order.swaps.length) {
for (const s of order.swaps) {
merged = [...merged, ...s.additional_items]
}
}
// Calculate which items are in the return
const returnItems = returnRequest.items.map((i) => {
const found = order.items.find((oi) => oi.id === i.item_id)
const found = merged.find((oi) => oi.id === i.item_id)
return {
...found,
quantity: i.quantity,
@@ -582,15 +598,30 @@ class SendGridService extends NotificationService {
const order = await this.orderService_.retrieve(swap.order_id, {
select: ["total"],
relations: ["items", "discounts", "shipping_address"],
relations: [
"items",
"discounts",
"shipping_address",
"swaps",
"swaps.additional_items",
],
})
const taxRate = order.tax_rate / 100
const currencyCode = order.currency_code.toUpperCase()
let merged = [...order.items]
// merge items from order with items from order swaps
if (order.swaps && order.swaps.length) {
for (const s of order.swaps) {
merged = [...merged, ...s.additional_items]
}
}
const returnItems = this.processItems_(
swap.return_order.items.map((i) => {
const found = order.items.find((oi) => oi.id === i.item_id)
const found = merged.find((oi) => oi.id === i.item_id)
return {
...found,
quantity: i.quantity,
@@ -655,15 +686,24 @@ class SendGridService extends NotificationService {
})
const order = await this.orderService_.retrieve(swap.order_id, {
relations: ["items", "discounts"],
relations: ["items", "discounts", "swaps", "swaps.additional_items"],
})
let merged = [...order.items]
// merge items from order with items from order swaps
if (order.swaps && order.swaps.length) {
for (const s of order.swaps) {
merged = [...merged, ...s.additional_items]
}
}
const taxRate = order.tax_rate / 100
const currencyCode = order.currency_code.toUpperCase()
const returnItems = this.processItems_(
swap.return_order.items.map((i) => {
const found = order.items.find((oi) => oi.id === i.item_id)
const found = merged.find((oi) => oi.id === i.item_id)
return {
...found,
quantity: i.quantity,

View File

@@ -51,6 +51,7 @@ const defaultFields = [
"updated_at",
"metadata",
"items.refundable",
"swaps.additional_items.refundable",
"shipping_total",
"discount_total",
"tax_total",
@@ -58,6 +59,7 @@ const defaultFields = [
"gift_card_total",
"subtotal",
"total",
"paid_total",
"refundable_amount",
]

View File

@@ -37,21 +37,18 @@ describe("POST /admin/orders/:id/return", () => {
it("calls OrderService return", () => {
expect(ReturnService.create).toHaveBeenCalledTimes(1)
expect(ReturnService.create).toHaveBeenCalledWith(
{
order_id: IdMap.getId("test-order"),
idempotency_key: "testkey",
items: [
{
item_id: IdMap.getId("existingLine"),
quantity: 10,
},
],
refund_amount: 10,
shipping_method: undefined,
},
orders.testOrder
)
expect(ReturnService.create).toHaveBeenCalledWith({
order_id: IdMap.getId("test-order"),
idempotency_key: "testkey",
items: [
{
item_id: IdMap.getId("existingLine"),
quantity: 10,
},
],
refund_amount: 10,
shipping_method: undefined,
})
})
})
@@ -88,21 +85,18 @@ describe("POST /admin/orders/:id/return", () => {
it("calls OrderService return", () => {
expect(ReturnService.create).toHaveBeenCalledTimes(1)
expect(ReturnService.create).toHaveBeenCalledWith(
{
order_id: IdMap.getId("test-order"),
idempotency_key: "testkey",
items: [
{
item_id: IdMap.getId("existingLine"),
quantity: 10,
},
],
refund_amount: 0,
shipping_method: undefined,
},
orders.testOrder
)
expect(ReturnService.create).toHaveBeenCalledWith({
order_id: IdMap.getId("test-order"),
idempotency_key: "testkey",
items: [
{
item_id: IdMap.getId("existingLine"),
quantity: 10,
},
],
refund_amount: 0,
shipping_method: undefined,
})
})
})
@@ -139,21 +133,18 @@ describe("POST /admin/orders/:id/return", () => {
it("calls OrderService return", () => {
expect(ReturnService.create).toHaveBeenCalledTimes(1)
expect(ReturnService.create).toHaveBeenCalledWith(
{
order_id: IdMap.getId("test-order"),
idempotency_key: "testkey",
items: [
{
item_id: IdMap.getId("existingLine"),
quantity: 10,
},
],
refund_amount: 0,
shipping_method: undefined,
},
orders.testOrder
)
expect(ReturnService.create).toHaveBeenCalledWith({
order_id: IdMap.getId("test-order"),
idempotency_key: "testkey",
items: [
{
item_id: IdMap.getId("existingLine"),
quantity: 10,
},
],
refund_amount: 0,
shipping_method: undefined,
})
})
})
@@ -194,24 +185,21 @@ describe("POST /admin/orders/:id/return", () => {
it("calls OrderService return", () => {
expect(ReturnService.create).toHaveBeenCalledTimes(1)
expect(ReturnService.create).toHaveBeenCalledWith(
{
order_id: IdMap.getId("test-order"),
idempotency_key: "testkey",
items: [
{
item_id: IdMap.getId("existingLine"),
quantity: 10,
},
],
refund_amount: 100,
shipping_method: {
option_id: "opt_1234",
price: 12,
expect(ReturnService.create).toHaveBeenCalledWith({
order_id: IdMap.getId("test-order"),
idempotency_key: "testkey",
items: [
{
item_id: IdMap.getId("existingLine"),
quantity: 10,
},
],
refund_amount: 100,
shipping_method: {
option_id: "opt_1234",
price: 12,
},
orders.testOrder
)
})
expect(ReturnService.fulfill).toHaveBeenCalledTimes(1)
expect(ReturnService.fulfill).toHaveBeenCalledWith("return")

View File

@@ -124,7 +124,7 @@ export default async (req, res) => {
.withTransaction(manager)
.retrieve(id, {
select: ["refunded_total", "total"],
relations: ["items", "swaps"],
relations: ["items", "swaps", "swaps.additional_items"],
})
const swap = await swapService

View File

@@ -78,14 +78,6 @@ export default app => {
middlewares.wrap(require("./request-return").default)
)
/**
* Register a requested return
*/
route.post(
"/:id/return/:return_id/receive",
middlewares.wrap(require("./receive-return").default)
)
/**
* Cancel an order.
*/
@@ -234,6 +226,7 @@ export const defaultFields = [
"updated_at",
"metadata",
"items.refundable",
"swaps.additional_items.refundable",
"shipping_total",
"discount_total",
"tax_total",
@@ -241,6 +234,7 @@ export const defaultFields = [
"gift_card_total",
"subtotal",
"total",
"paid_total",
"refundable_amount",
]
@@ -267,6 +261,7 @@ export const allowedFields = [
"subtotal",
"gift_card_total",
"total",
"paid_total",
"refundable_amount",
]

View File

@@ -123,13 +123,6 @@ export default async (req, res) => {
const { key, error } = await idempotencyKeyService.workStage(
idempotencyKey.idempotency_key,
async manager => {
const order = await orderService
.withTransaction(manager)
.retrieve(id, {
select: ["refunded_total", "total"],
relations: ["items"],
})
const returnObj = {
order_id: id,
idempotency_key: idempotencyKey.idempotency_key,
@@ -150,7 +143,7 @@ export default async (req, res) => {
const createdReturn = await returnService
.withTransaction(manager)
.create(returnObj, order)
.create(returnObj)
if (value.return_shipping) {
await returnService
@@ -208,7 +201,7 @@ export default async (req, res) => {
order = await returnService
.withTransaction(manager)
.receiveReturn(order.id, ret.id, value.items, value.refund)
.receive(ret.id, value.items, value.refund)
}
order = await orderService.withTransaction(manager).retrieve(id, {

View File

@@ -0,0 +1,58 @@
import { IdMap } from "medusa-test-utils"
import { request } from "../../../../../helpers/test-request"
import { OrderServiceMock } from "../../../../../services/__mocks__/order"
import { ReturnService } from "../../../../../services/__mocks__/return"
describe("POST /admin/returns/:id/receive", () => {
describe("successfully receives a return", () => {
let subject
beforeAll(async () => {
subject = await request(
"POST",
`/admin/returns/${IdMap.getId("test-return")}/receive`,
{
payload: {
items: [
{
item_id: IdMap.getId("test"),
quantity: 2,
},
],
},
adminSession: {
jwt: {
userId: IdMap.getId("admin_user"),
},
},
}
)
})
it("returns 200", () => {
expect(subject.status).toEqual(200)
})
it("calls ReturnService receive", () => {
expect(ReturnService.receive).toHaveBeenCalledTimes(1)
expect(ReturnService.receive).toHaveBeenCalledWith(
IdMap.getId("test-return"),
[{ item_id: IdMap.getId("test"), quantity: 2 }],
undefined,
true
)
})
it("calls OrderService registerReturnReceived", () => {
expect(OrderServiceMock.registerReturnReceived).toHaveBeenCalledTimes(1)
expect(OrderServiceMock.registerReturnReceived).toHaveBeenCalledWith(
IdMap.getId("test-order"),
{
id: IdMap.getId("test-return"),
order_id: IdMap.getId("test-order"),
},
undefined
)
})
})
})

View File

@@ -11,5 +11,10 @@ export default app => {
*/
route.get("/", middlewares.wrap(require("./list-returns").default))
route.post(
"/:id/receive",
middlewares.wrap(require("./receive-return").default)
)
return app
}

View File

@@ -1,14 +1,12 @@
import { MedusaError, Validator } from "medusa-core-utils"
import { defaultRelations, defaultFields } from "./"
/**
* @oas [post] /orders/{id}/returns/{return_id}/receive
* operationId: "PostOrdersOrderReturnsReturnReceive"
* @oas [post] /returns/{id}receive
* operationId: "PostReturnsReturnReceive"
* summary: "Receive a Return"
* description: "Registers a Return as received."
* description: "Registers a Return as received. Updates statuses on Orders and Swaps accordingly."
* parameters:
* - (path) id=* {string} The id of the Order.
* - (path) return_id=* {string} The id of the Return.
* - (path) id=* {string} The id of the Return.
* requestBody:
* content:
* application/json:
@@ -29,7 +27,7 @@ import { defaultRelations, defaultFields } from "./"
* description: The amount to refund.
* type: integer
* tags:
* - Order
* - Return
* responses:
* 200:
* description: OK
@@ -37,11 +35,11 @@ import { defaultRelations, defaultFields } from "./"
* application/json:
* schema:
* properties:
* order:
* $ref: "#/components/schemas/order"
* return:
* $ref: "#/components/schemas/return"
*/
export default async (req, res) => {
const { id, return_id } = req.params
const { id } = req.params
const schema = Validator.object().keys({
items: Validator.array()
@@ -61,28 +59,43 @@ export default async (req, res) => {
}
try {
const returnService = req.scope.resolve("returnService")
const orderService = req.scope.resolve("orderService")
const swapService = req.scope.resolve("swapService")
const entityManager = req.scope.resolve("manager")
let refundAmount = value.refund
let receivedReturn
await entityManager.transaction(async manager => {
let refundAmount = value.refund
if (typeof value.refund !== "undefined" && value.refund < 0) {
refundAmount = 0
}
if (typeof value.refund !== "undefined" && value.refund < 0) {
refundAmount = 0
}
let order = await orderService.receiveReturn(
id,
return_id,
value.items,
refundAmount,
true
)
receivedReturn = await returnService
.withTransaction(manager)
.receive(id, value.items, refundAmount, true)
order = await orderService.retrieve(id, {
select: defaultFields,
relations: defaultRelations,
if (receivedReturn.order_id) {
await orderService
.withTransaction(manager)
.registerReturnReceived(
receivedReturn.order_id,
receivedReturn,
refundAmount
)
}
if (receivedReturn.swap_id) {
await swapService
.withTransaction(manager)
.registerReceived(receivedReturn.swap_id)
}
})
res.status(200).json({ order })
receivedReturn = await returnService.retrieve(id, { relations: ["swap"] })
res.status(200).json({ return: receivedReturn })
} catch (err) {
throw err
}

View File

@@ -247,6 +247,7 @@ export class Order {
refunded_total: number
total: number
subtotal: number
paid_total: number
refundable_amount: number
gift_card_total: number
@@ -403,4 +404,6 @@ export class Order {
* type: integer
* gift_card_total:
* type: integer
* paid_total:
* type: integer
*/

View File

@@ -126,9 +126,13 @@ export const OrderServiceMock = {
withTransaction: function() {
return this
},
create: jest.fn().mockImplementation(data => {
return Promise.resolve(orders.testOrder)
}),
registerReturnReceived: jest.fn().mockImplementation(data => {
return Promise.resolve()
}),
createFromCart: jest.fn().mockImplementation(data => {
return Promise.resolve(orders.testOrder)
}),
@@ -197,12 +201,6 @@ export const OrderServiceMock = {
}
return Promise.resolve(undefined)
}),
requestReturn: jest.fn().mockImplementation(order => {
if (order === IdMap.getId("test-order")) {
return Promise.resolve(orders.testOrder)
}
return Promise.resolve(undefined)
}),
receiveReturn: jest.fn().mockImplementation(order => {
if (order === IdMap.getId("test-order")) {
return Promise.resolve(orders.testOrder)

View File

@@ -1,3 +1,5 @@
import { IdMap } from "medusa-test-utils"
export const ReturnService = {
withTransaction: function() {
return this
@@ -5,6 +7,13 @@ export const ReturnService = {
create: jest.fn(() => Promise.resolve({ id: "return" })),
fulfill: jest.fn(),
update: jest.fn(),
receive: jest.fn(() =>
Promise.resolve({
id: IdMap.getId("test-return"),
order_id: IdMap.getId("test-order"),
})
),
retrieve: jest.fn(() => Promise.resolve("test-return")),
}
const mock = jest.fn().mockImplementation(() => {

View File

@@ -25,6 +25,9 @@ describe("OrderService", () => {
getSubtotal: o => {
return o.subtotal || 0
},
getPaidTotal: o => {
return o.paid_total || 0
},
}
const eventBusService = {
@@ -858,16 +861,20 @@ describe("OrderService", () => {
})
})
describe("receiveReturn", () => {
describe("registerReturnReceived", () => {
const order = {
items: [
{
id: "item_1",
quantity: 10,
returned_quantity: 0,
returned_quantity: 10,
},
],
payments: [{ id: "payment_test" }],
payments: [{ id: "payment_test", amount: 100 }],
refunded_total: 0,
paid_total: 100,
refundable_amount: 100,
total: 100,
}
const orderRepo = MockRepository({
findOneWithRelations: (rel, q) => {
@@ -878,24 +885,6 @@ describe("OrderService", () => {
},
})
const returnService = {
retrieve: () => {
return Promise.resolve({
order_id: IdMap.getId("order"),
})
},
receiveReturn: jest
.fn()
.mockImplementation((id, items, amount, mism) =>
id === IdMap.getId("good")
? Promise.resolve({ items, status: "received", refund_amount: 100 })
: Promise.resolve({ status: "requires_action" })
),
withTransaction: function() {
return this
},
}
const paymentProviderService = {
refundPayment: jest
.fn()
@@ -907,20 +896,11 @@ describe("OrderService", () => {
},
}
const lineItemService = {
update: jest.fn(),
withTransaction: function() {
return this
},
}
const orderService = new OrderService({
manager: MockManager,
orderRepository: orderRepo,
paymentProviderService,
totalsService,
returnService,
lineItemService,
eventBusService,
})
@@ -929,29 +909,11 @@ describe("OrderService", () => {
})
it("calls order model functions", async () => {
const items = [
{
item_id: "item_1",
quantity: 10,
},
]
await orderService.receiveReturn(
IdMap.getId("order"),
IdMap.getId("good"),
items
)
expect(returnService.receiveReturn).toHaveBeenCalledTimes(1)
expect(returnService.receiveReturn).toHaveBeenCalledWith(
IdMap.getId("good"),
items,
undefined,
false
)
expect(lineItemService.update).toHaveBeenCalledTimes(1)
expect(lineItemService.update).toHaveBeenCalledWith("item_1", {
returned_quantity: 10,
await orderService.registerReturnReceived(IdMap.getId("order"), {
id: IdMap.getId("good"),
order_id: IdMap.getId("order"),
status: "received",
refund_amount: 100,
})
expect(orderRepo.save).toHaveBeenCalledTimes(1)
@@ -969,136 +931,23 @@ describe("OrderService", () => {
})
it("return with custom refund", async () => {
const items = [
{
item_id: IdMap.getId("existingLine"),
quantity: 10,
},
]
await orderService.receiveReturn(
await orderService.registerReturnReceived(
IdMap.getId("order"),
IdMap.getId("good"),
items,
102
)
expect(returnService.receiveReturn).toHaveBeenCalledTimes(1)
expect(returnService.receiveReturn).toHaveBeenCalledWith(
IdMap.getId("good"),
items,
102,
false
)
})
it("calls order model functions and sets partially_returned", async () => {
const items = [
{
item_id: IdMap.getId("existingLine"),
quantity: 2,
id: IdMap.getId("good"),
order_id: IdMap.getId("order"),
status: "received",
refund_amount: 95,
},
]
await orderService.receiveReturn(
IdMap.getId("order"),
IdMap.getId("good"),
items
95
)
expect(orderRepo.save).toHaveBeenCalledTimes(1)
expect(orderRepo.save).toHaveBeenCalledWith({
...order,
fulfillment_status: "partially_returned",
})
})
it("sets requires_action on additional items", async () => {
await orderService.receiveReturn(
IdMap.getId("order"),
IdMap.getId("action"),
[
{
item_id: IdMap.getId("existingLine2"),
quantity: 2,
},
]
expect(paymentProviderService.refundPayment).toHaveBeenCalledTimes(1)
expect(paymentProviderService.refundPayment).toHaveBeenCalledWith(
order.payments,
95,
"return"
)
expect(orderRepo.save).toHaveBeenCalledTimes(1)
expect(orderRepo.save).toHaveBeenCalledWith({
...order,
fulfillment_status: "requires_action",
})
})
})
describe("requestReturn", () => {
const order = {
items: [
{
id: "item_1",
quantity: 10,
returned_quantity: 0,
},
],
payments: [{ id: "payment_test" }],
}
const orderRepo = MockRepository({
findOneWithRelations: (rel, q) => {
switch (q.where.id) {
default:
return Promise.resolve(order)
}
},
})
const returnService = {
create: jest.fn(() => Promise.resolve({ id: "ret" })),
fulfill: jest.fn(() => Promise.resolve({ id: "ret" })),
withTransaction: function() {
return this
},
}
const orderService = new OrderService({
manager: MockManager,
orderRepository: orderRepo,
totalsService,
returnService,
eventBusService,
})
beforeEach(async () => {
jest.clearAllMocks()
})
it("successfully creates return request", async () => {
const items = [
{
item_id: IdMap.getId("existingLine"),
quantity: 10,
},
]
const shipping_method = {
id: IdMap.getId("return-shipping"),
price: 2,
}
await orderService.requestReturn(
"processed-order",
items,
shipping_method
)
expect(returnService.create).toHaveBeenCalledTimes(1)
expect(returnService.create).toHaveBeenCalledWith(
{
items,
shipping_method,
refund_amount: undefined,
order_id: "processed-order",
},
order
)
expect(returnService.fulfill).toHaveBeenCalledWith("ret")
})
})
@@ -1201,125 +1050,6 @@ describe("OrderService", () => {
})
})
describe("registerSwapReceived", () => {
beforeEach(async () => {
jest.clearAllMocks()
})
const orderRepo = MockRepository({
findOneWithRelations: () => Promise.resolve({ id: IdMap.getId("order") }),
})
it("fails if order/swap relationship not satisfied", async () => {
const swapService = {
retrieve: jest
.fn()
.mockReturnValue(
Promise.resolve({ id: "1235", order_id: IdMap.getId("order_1") })
),
withTransaction: function() {
return this
},
}
const orderService = new OrderService({
manager: MockManager,
orderRepository: orderRepo,
totalsService,
swapService,
eventBusService,
})
const res = orderService.registerSwapReceived(
IdMap.getId("order"),
"1235"
)
await expect(res).rejects.toThrow("Swap must belong to the given order")
})
it("fails if swap doesn't have status received", async () => {
const swapService = {
retrieve: jest.fn().mockReturnValue(
Promise.resolve({
id: "1235",
order_id: IdMap.getId("order"),
return_order: { status: "requested" },
})
),
withTransaction: function() {
return this
},
}
const orderService = new OrderService({
manager: MockManager,
orderRepository: orderRepo,
swapService,
totalsService,
eventBusService: { emit: jest.fn().mockReturnValue(Promise.resolve()) },
})
const res = orderService.registerSwapReceived(
IdMap.getId("order"),
"1235"
)
await expect(res).rejects.toThrow("Swap is not received")
})
it("registers a swap as received", async () => {
const orderRepo = MockRepository({
findOneWithRelations: () =>
Promise.resolve({
id: IdMap.getId("order_123"),
items: [
{
id: IdMap.getId("1234"),
returned_quantity: 0,
quantity: 1,
},
],
}),
})
const swapService = {
retrieve: jest.fn().mockReturnValue(
Promise.resolve({
id: "1235",
order_id: IdMap.getId("order_123"),
return_order: {
status: "received",
items: [{ item_id: IdMap.getId("1234"), quantity: 1 }],
},
})
),
withTransaction: function() {
return this
},
}
const lineItemService = {
update: jest.fn(),
withTransaction: function() {
return this
},
}
const orderService = new OrderService({
manager: MockManager,
orderRepository: orderRepo,
totalsService,
swapService,
lineItemService,
eventBusService,
})
await orderService.registerSwapReceived(IdMap.getId("order_123"), "1235")
expect(lineItemService.update).toHaveBeenCalledTimes(1)
expect(lineItemService.update).toHaveBeenCalledWith(IdMap.getId("1234"), {
returned_quantity: 1,
})
})
})
describe("createRefund", () => {
beforeEach(async () => {
jest.clearAllMocks()
@@ -1341,13 +1071,15 @@ describe("OrderService", () => {
}
return Promise.resolve({
id: IdMap.getId("order"),
id: IdMap.getId("order_123"),
payments: [
{
id: "payment",
},
],
total: 100,
paid_total: 100,
refundable_amount: 100,
refunded_total: 0,
})
},

View File

@@ -1,5 +1,4 @@
import { IdMap, MockManager, MockRepository } from "medusa-test-utils"
import idMap from "medusa-test-utils/dist/id-map"
import ReturnService from "../return"
describe("ReturnService", () => {
@@ -141,7 +140,7 @@ describe("ReturnService", () => {
// })
// })
describe("receiveReturn", () => {
describe("receive", () => {
const returnRepository = MockRepository({
findOne: query => {
if (query.where.id === IdMap.getId("test-return-2")) {
@@ -189,9 +188,44 @@ describe("ReturnService", () => {
}),
}
const orderService = {
retrieve: jest.fn().mockImplementation(() => {
return Promise.resolve({
items: [
{
id: IdMap.getId("test-line"),
quantity: 10,
returned_quantity: 0,
},
{
id: IdMap.getId("test-line-2"),
quantity: 10,
returned_quantity: 0,
},
],
payments: [{ id: "payment_test" }],
})
}),
withTransaction: function() {
return this
},
}
const lineItemService = {
retrieve: jest.fn().mockImplementation(data => {
return Promise.resolve({ ...data, returned_quantity: 0 })
}),
update: jest.fn(),
withTransaction: function() {
return this
},
}
const returnService = new ReturnService({
manager: MockManager,
totalsService,
lineItemService,
orderService,
returnRepository,
})
@@ -200,7 +234,7 @@ describe("ReturnService", () => {
})
it("successfully receives a return", async () => {
await returnService.receiveReturn(
await returnService.receive(
IdMap.getId("test-return"),
[{ item_id: IdMap.getId("test-line"), quantity: 10 }],
1000
@@ -226,10 +260,18 @@ describe("ReturnService", () => {
refund_amount: 1000,
received_at: expect.anything(),
})
expect(lineItemService.update).toHaveBeenCalledTimes(1)
expect(lineItemService.update).toHaveBeenCalledWith(
IdMap.getId("test-line"),
{
returned_quantity: 10,
}
)
})
it("successfully receives a return with requires_action status", async () => {
await returnService.receiveReturn(
await returnService.receive(
IdMap.getId("test-return-2"),
[
{ item_id: IdMap.getId("test-line"), quantity: 10 },

View File

@@ -192,6 +192,8 @@ describe("SwapService", () => {
relations: [
"order",
"order.items",
"order.swaps",
"order.swaps.additional_items",
"order.discounts",
"additional_items",
"return_order",
@@ -864,5 +866,56 @@ describe("SwapService", () => {
)
})
})
describe("registerReceived", () => {
beforeEach(async () => {
jest.clearAllMocks()
})
const eventBusService = {
emit: jest.fn().mockReturnValue(Promise.resolve()),
withTransaction: function() {
return this
},
}
const swapRepo = MockRepository({
findOne: q => {
switch (q.where.id) {
case "requested":
return Promise.resolve({
id: "requested",
order_id: IdMap.getId("order"),
return_order: { status: "requested" },
})
case "received":
return Promise.resolve({
id: "received",
order_id: IdMap.getId("order"),
return_order: { status: "received" },
})
default:
return Promise.resolve()
}
},
})
const swapService = new SwapService({
manager: MockManager,
swapRepository: swapRepo,
eventBusService,
})
it("fails if swap doesn't have status received", async () => {
const res = swapService.registerReceived("requested")
await expect(res).rejects.toThrow("Swap is not received")
})
it("registers a swap as received", async () => {
await swapService.registerReceived("received")
expect(eventBusService.emit).toHaveBeenCalledTimes(1)
})
})
})
})

View File

@@ -16,7 +16,6 @@ class OrderService extends BaseService {
REFUND_CREATED: "order.refund_created",
REFUND_FAILED: "order.refund_failed",
SWAP_CREATED: "order.swap_created",
SWAP_RECEIVED: "order.swap_received",
PLACED: "order.placed",
UPDATED: "order.updated",
CANCELED: "order.canceled",
@@ -36,8 +35,6 @@ class OrderService extends BaseService {
lineItemService,
totalsService,
regionService,
returnService,
swapService,
cartService,
addressRepository,
giftCardService,
@@ -54,10 +51,10 @@ class OrderService extends BaseService {
/** @private @constant {CustomerService} */
this.customerService_ = customerService
/** @private @constantant {PaymentProviderService} */
/** @private @constant {PaymentProviderService} */
this.paymentProviderService_ = paymentProviderService
/** @private @constantant {ShippingProvileService} */
/** @private @constant {ShippingProvileService} */
this.shippingProfileService_ = shippingProfileService
/** @private @constant {FulfillmentProviderService} */
@@ -72,9 +69,6 @@ class OrderService extends BaseService {
/** @private @constant {RegionService} */
this.regionService_ = regionService
/** @private @constant {ReturnService} */
this.returnService_ = returnService
/** @private @constant {FulfillmentService} */
this.fulfillmentService_ = fulfillmentService
@@ -95,9 +89,6 @@ class OrderService extends BaseService {
/** @private @constant {AddressRepository} */
this.addressRepository_ = addressRepository
/** @private @constant {SwapService} */
this.swapService_ = swapService
}
withTransaction(manager) {
@@ -118,7 +109,6 @@ class OrderService extends BaseService {
discountService: this.discountService_,
totalsService: this.totalsService_,
cartService: this.cartService_,
swapService: this.swapService_,
giftCardService: this.giftCardService_,
})
@@ -276,15 +266,18 @@ class OrderService extends BaseService {
"discount_total",
"gift_card_total",
"total",
"paid_total",
"refunded_total",
"refundable_amount",
"items.refundable",
"swaps.additional_items.refundable",
]
const totalsToSelect = select.filter(v => totalFields.includes(v))
if (totalsToSelect.length > 0) {
const relationSet = new Set(relations)
relationSet.add("items")
relationSet.add("swaps")
relationSet.add("discounts")
relationSet.add("gift_cards")
relationSet.add("gift_card_transactions")
@@ -1068,177 +1061,6 @@ class OrderService extends BaseService {
return toReturn.filter(i => !!i)
}
/**
* Checks that a given quantity of a line item can be returned. Fails if the
* item is undefined or if the returnable quantity of the item is lower, than
* the quantity that is requested to be returned.
* @param {LineItem?} item - the line item to check has sufficient returnable
* quantity.
* @param {number} quantity - the quantity that is requested to be returned.
* @return {LineItem} a line item where the quantity is set to the requested
* return quantity.
*/
validateReturnLineItem_(item, quantity) {
if (!item) {
throw new MedusaError(
MedusaError.Types.INVALID_DATA,
"Return contains invalid line item"
)
}
const returnable = item.quantity - (item.returned_quantity || 0)
if (quantity > returnable) {
throw new MedusaError(
MedusaError.Types.NOT_ALLOWED,
"Cannot return more items than have been purchased"
)
}
return {
...item,
quantity,
}
}
/**
* Creates a return request for an order, with given items, and a shipping
* method. If no refundAmount is provided the refund amount is calculated from
* the return lines and the shipping cost.
* @param {String} orderId - the id of the order to create a return for.
* @param {Array<{item_id: String, quantity: Int}>} items - the line items to
* return
* @param {ShippingMethod?} shippingMethod - the shipping method used for the
* return
* @param {Number?} refundAmount - the amount to refund when the return is
* received.
* @returns {Promise<Order>} the resulting order.
*/
async requestReturn(orderId, items, shippingMethod, refundAmount) {
return this.atomicPhase_(async manager => {
const order = await this.retrieve(orderId, {
select: ["refunded_total", "total"],
relations: ["items"],
})
const returnObj = {
order_id: orderId,
items,
shipping_method: shippingMethod,
refund_amount: refundAmount,
}
const returnRequest = await this.returnService_
.withTransaction(manager)
.create(returnObj, order)
const fulfilledReturn = await this.returnService_
.withTransaction(manager)
.fulfill(returnRequest.id)
const result = await this.retrieve(orderId)
this.eventBus_
.withTransaction(manager)
.emit(OrderService.Events.RETURN_REQUESTED, {
id: result.id,
return_id: fulfilledReturn.id,
})
return result
})
}
/**
* Registers a previously requested return as received. This will create a
* refund to the customer. If the returned items don't match the requested
* items the return status will be updated to requires_action. This behaviour
* is useful in sitautions where a custom refund amount is requested, but the
* retuned items are not matching the requested items. Setting the
* allowMismatch argument to true, will process the return, ignoring any
* mismatches.
* @param {string} orderId - the order to return.
* @param {string[]} lineItems - the line items to return
* @return {Promise} the result of the update operation
*/
async receiveReturn(
orderId,
returnId,
items,
refundAmount,
allowMismatch = false
) {
return this.atomicPhase_(async manager => {
const order = await this.retrieve(orderId, {
relations: ["items", "returns", "payments"],
})
const returnRequest = await this.returnService_.retrieve(returnId)
if (!returnRequest || returnRequest.order_id !== orderId) {
throw new MedusaError(
MedusaError.Types.NOT_FOUND,
`Return request with id ${returnId} was not found`
)
}
const updatedReturn = await this.returnService_
.withTransaction(manager)
.receiveReturn(returnId, items, refundAmount, allowMismatch)
const orderRepo = manager.getCustomRepository(this.orderRepository_)
if (updatedReturn.status === "requires_action") {
order.fulfillment_status = "requires_action"
const result = await orderRepo.save(order)
this.eventBus_
.withTransaction(manager)
.emit(OrderService.Events.RETURN_ACTION_REQUIRED, {
id: result.id,
return_id: updatedReturn.id,
})
return result
}
let isFullReturn = true
for (const i of order.items) {
const isReturn = updatedReturn.items.find(r => i.id === r.item_id)
if (isReturn) {
const returnedQuantity =
(i.returned_quantity || 0) + isReturn.quantity
if (i.quantity !== returnedQuantity) {
isFullReturn = false
}
await this.lineItemService_.withTransaction(manager).update(i.id, {
returned_quantity: returnedQuantity,
})
} else {
if (!i.returned_quantity !== i.quantity) {
isFullReturn = false
}
}
}
if (updatedReturn.refund_amount > 0) {
await this.paymentProviderService_
.withTransaction(manager)
.refundPayment(order.payments, updatedReturn.refund_amount, "return")
}
if (isFullReturn) {
order.fulfillment_status = "returned"
} else {
order.fulfillment_status = "partially_returned"
}
const result = await orderRepo.save(order)
await this.eventBus_
.withTransaction(manager)
.emit(OrderService.Events.ITEMS_RETURNED, {
id: order.id,
return_id: updatedReturn.id,
})
return result
})
}
/**
* Archives an order. It only alloved, if the order has been fulfilled
* and payment has been captured.
@@ -1315,10 +1137,13 @@ class OrderService extends BaseService {
if (totalsFields.includes("refunded_total")) {
order.refunded_total = this.totalsService_.getRefundedTotal(order)
}
if (totalsFields.includes("paid_total")) {
order.paid_total = this.totalsService_.getPaidTotal(order)
}
if (totalsFields.includes("refundable_amount")) {
const total = this.totalsService_.getTotal(order)
const paid_total = this.totalsService_.getPaidTotal(order)
const refunded_total = this.totalsService_.getRefundedTotal(order)
order.refundable_amount = total - refunded_total
order.refundable_amount = paid_total - refunded_total
}
if (totalsFields.includes("items.refundable")) {
@@ -1331,86 +1156,94 @@ class OrderService extends BaseService {
}))
}
if (
totalsFields.includes("swaps.additional_items.refundable") &&
order.swaps &&
order.swaps.length
) {
for (const s of order.swaps) {
s.additional_items = s.additional_items.map(i => ({
...i,
refundable: this.totalsService_.getLineItemRefund(order, {
...i,
quantity: i.quantity - (i.returned_quantity || 0),
}),
}))
}
}
return order
}
/**
* Dedicated method to set metadata for an order.
* To ensure that plugins does not overwrite each
* others metadata fields, setMetadata is provided.
* @param {string} orderId - the order to decorate.
* @param {string} key - key for metadata field
* @param {string} value - value for metadata field.
* @return {Promise} resolves to the updated result.
* Handles receiving a return. This will create a
* refund to the customer. If the returned items don't match the requested
* items the return status will be updated to requires_action. This behaviour
* is useful in sitautions where a custom refund amount is requested, but the
* retuned items are not matching the requested items. Setting the
* allowMismatch argument to true, will process the return, ignoring any
* mismatches.
* @param {string} orderId - the order to return.
* @param {object} receivedReturn - the received return
* @return {Promise} the result of the update operation
*/
setMetadata_(order, metadata) {
const existing = order.metadata || {}
const newData = {}
for (const [key, value] of Object.entries(metadata)) {
if (typeof key !== "string") {
throw new MedusaError(
MedusaError.Types.INVALID_ARGUMENT,
"Key type is invalid. Metadata keys must be strings"
)
}
newData[key] = value
}
const updated = {
...existing,
...newData,
}
return updated
}
/**
* Registers the swap return items as received so that they cannot be used
* as a part of other swaps/returns.
* @param {string} id - the id of the order with the swap.
* @param {string} swapId - the id of the swap that has been received.
* @returns {Promise<Order>} the resulting order
*/
async registerSwapReceived(id, swapId) {
async registerReturnReceived(orderId, receivedReturn, customRefundAmount) {
return this.atomicPhase_(async manager => {
const order = await this.retrieve(id, { relations: ["items"] })
const swap = await this.swapService_
.withTransaction(manager)
.retrieve(swapId, { relations: ["return_order", "return_order.items"] })
const order = await this.retrieve(orderId, {
select: ["total", "refunded_total", "refundable_amount"],
relations: ["items", "returns", "payments"],
})
if (!swap || swap.order_id !== id) {
if (!receivedReturn || receivedReturn.order_id !== orderId) {
throw new MedusaError(
MedusaError.Types.NOT_ALLOWED,
"Swap must belong to the given order"
MedusaError.Types.NOT_FOUND,
`Received return does not exist`
)
}
if (swap.return_order.status !== "received") {
throw new MedusaError(
MedusaError.Types.NOT_ALLOWED,
"Swap is not received"
)
}
let refundAmount = customRefundAmount || receivedReturn.refund_amount
for (const i of order.items) {
const isReturn = swap.return_order.items.find(ri => i.id === ri.item_id)
const orderRepo = manager.getCustomRepository(this.orderRepository_)
if (isReturn) {
const returnedQuantity =
(i.returned_quantity || 0) + isReturn.quantity
await this.lineItemService_.withTransaction(manager).update(i.id, {
returned_quantity: returnedQuantity,
if (refundAmount > order.refundable_amount) {
order.fulfillment_status = "requires_action"
const result = await orderRepo.save(order)
this.eventBus_
.withTransaction(manager)
.emit(OrderService.Events.RETURN_ACTION_REQUIRED, {
id: result.id,
return_id: receivedReturn.id,
})
return result
}
let isFullReturn = true
for (const i of order.items) {
if (i.returned_quantity !== i.quantity) {
isFullReturn = false
}
}
const result = await this.retrieve(id)
if (receivedReturn.refund_amount > 0) {
const refund = await this.paymentProviderService_
.withTransaction(manager)
.refundPayment(order.payments, receivedReturn.refund_amount, "return")
order.refunds = [...(order.refunds || []), refund]
}
if (isFullReturn) {
order.fulfillment_status = "returned"
} else {
order.fulfillment_status = "partially_returned"
}
const result = await orderRepo.save(order)
await this.eventBus_
.withTransaction(manager)
.emit(OrderService.Events.SWAP_RECEIVED, {
id: result.id,
swap_id: swapId,
.emit(OrderService.Events.ITEMS_RETURNED, {
id: order.id,
return_id: receivedReturn.id,
})
return result
})

View File

@@ -396,13 +396,15 @@ class PaymentProviderService extends BaseService {
}
const refundRepo = manager.getCustomRepository(this.refundRepository_)
const created = refundRepo.create({
const toCreate = {
order_id,
amount,
reason,
note,
})
}
const created = refundRepo.create(toCreate)
return refundRepo.save(created)
})
}

View File

@@ -16,6 +16,7 @@ class ReturnService extends BaseService {
shippingOptionService,
returnReasonService,
fulfillmentProviderService,
orderService,
}) {
super()
@@ -41,6 +42,9 @@ class ReturnService extends BaseService {
this.fulfillmentProviderService_ = fulfillmentProviderService
this.returnReasonService_ = returnReasonService
/** @private @const {OrderService} */
this.orderService_ = orderService
}
withTransaction(transactionManager) {
@@ -57,6 +61,7 @@ class ReturnService extends BaseService {
shippingOptionService: this.shippingOptionService_,
fulfillmentProviderService: this.fulfillmentProviderService_,
returnReasonService: this.returnReasonService_,
orderService: this.orderService_,
})
cloned.transactionManager_ = transactionManager
@@ -65,7 +70,7 @@ class ReturnService extends BaseService {
}
/**
* Retrieves the order line items, given an array of items.
* Retrieves the order line items, given an array of items
* @param {Order} order - the order to get line items from
* @param {{ item_id: string, quantity: number }} items - the items to get
* @param {function} transformer - a function to apply to each of the items
@@ -75,9 +80,18 @@ class ReturnService extends BaseService {
* @return {Promise<Array<LineItem>>} the line items generated by the transformer.
*/
async getFulfillmentItems_(order, items, transformer) {
let merged = [...order.items]
// merge items from order with items from order swaps
if (order.swaps && order.swaps.length) {
for (const s of order.swaps) {
merged = [...merged, ...s.additional_items]
}
}
const toReturn = await Promise.all(
items.map(async data => {
const item = order.items.find(i => i.id === data.item_id)
const item = merged.find(i => i.id === data.item_id)
return transformer(item, data.quantity, data)
})
)
@@ -243,14 +257,26 @@ class ReturnService extends BaseService {
* @param {object} orderLike - order object
* @returns {Promise<Return>} the resulting order.
*/
async create(data, orderLike) {
async create(data) {
return this.atomicPhase_(async manager => {
const returnRepository = manager.getCustomRepository(
this.returnRepository_
)
let orderId = data.order_id
if (data.swap_id) {
delete data.order_id
}
const order = await this.orderService_
.withTransaction(manager)
.retrieve(orderId, {
select: ["refunded_total", "total", "refundable_amount"],
relations: ["swaps", "swaps.additional_items", "items"],
})
const returnLines = await this.getFulfillmentItems_(
orderLike,
order,
data.items,
this.validateReturnLineItem_
)
@@ -266,7 +292,9 @@ class ReturnService extends BaseService {
let toRefund = data.refund_amount
if (typeof toRefund !== "undefined") {
const refundable = orderLike.total - orderLike.refunded_total
// refundable from order
let refundable = order.refundable_amount
if (toRefund > refundable) {
throw new MedusaError(
MedusaError.Types.INVALID_DATA,
@@ -274,16 +302,12 @@ class ReturnService extends BaseService {
)
}
} else {
toRefund = await this.totalsService_.getRefundTotal(
orderLike,
returnLines
)
toRefund = await this.totalsService_.getRefundTotal(order, returnLines)
if (data.shipping_method) {
toRefund = Math.max(
0,
toRefund -
data.shipping_method.price * (1 + orderLike.tax_rate / 100)
toRefund - data.shipping_method.price * (1 + order.tax_rate / 100)
)
}
}
@@ -341,11 +365,13 @@ class ReturnService extends BaseService {
],
})
let returnData = { ...returnOrder }
const items = await this.lineItemService_.list({
id: returnOrder.items.map(({ item_id }) => item_id),
})
returnOrder.items = returnOrder.items.map(item => {
returnData.items = returnOrder.items.map(item => {
const found = items.find(i => i.id === item.item_id)
return {
...item,
@@ -365,7 +391,7 @@ class ReturnService extends BaseService {
}
const fulfillmentData = await this.fulfillmentProviderService_.createReturn(
returnOrder
returnData
)
returnOrder.shipping_data = fulfillmentData
@@ -388,36 +414,37 @@ class ReturnService extends BaseService {
* @param {string[]} lineItems - the line items to return
* @return {Promise} the result of the update operation
*/
async receiveReturn(
returnId,
receivedItems,
refundAmount,
allowMismatch = false
) {
async receive(returnId, receivedItems, refundAmount, allowMismatch = false) {
return this.atomicPhase_(async manager => {
const returnRepository = manager.getCustomRepository(
this.returnRepository_
)
const returnObj = await this.retrieve(returnId, {
relations: [
"items",
"order",
"order.items",
"order.discounts",
"order.refunds",
"order.shipping_methods",
"order.region",
"swap",
"swap.order",
"swap.order.items",
"swap.order.refunds",
"swap.order.shipping_methods",
"swap.order.region",
],
relations: ["items", "swap", "swap.additional_items"],
})
const order = returnObj.order || (returnObj.swap && returnObj.swap.order)
let orderId = returnObj.order_id
// check if return is requested on a swap
if (returnObj.swap) {
orderId = returnObj.swap.order_id
}
const order = await this.orderService_
.withTransaction(manager)
.retrieve(orderId, {
relations: [
"items",
"returns",
"payments",
"discounts",
"refunds",
"shipping_methods",
"region",
"swaps",
"swaps.additional_items",
],
})
if (returnObj.status === "received") {
throw new MedusaError(
@@ -462,24 +489,29 @@ class ReturnService extends BaseService {
returnStatus = "requires_action"
}
const toRefund = refundAmount || returnObj.refund_amount
const total = await this.totalsService_.getTotal(order)
const refunded = await this.totalsService_.getRefundedTotal(order)
if (toRefund > total - refunded) {
returnStatus = "requires_action"
}
const totalRefundableAmount = refundAmount || returnObj.refund_amount
const now = new Date()
const updateObj = {
...returnObj,
status: returnStatus,
items: newLines,
refund_amount: toRefund,
refund_amount: totalRefundableAmount,
received_at: now.toISOString(),
}
const result = await returnRepository.save(updateObj)
for (const i of returnObj.items) {
const lineItem = await this.lineItemService_
.withTransaction(manager)
.retrieve(i.item_id)
const returnedQuantity = (lineItem.returned_quantity || 0) + i.quantity
await this.lineItemService_.withTransaction(manager).update(i.item_id, {
returned_quantity: returnedQuantity,
})
}
return result
})
}

View File

@@ -9,6 +9,7 @@ import { MedusaError } from "medusa-core-utils"
class SwapService extends BaseService {
static Events = {
CREATED: "swap.created",
RECEIVED: "swap.received",
SHIPMENT_CREATED: "swap.shipment_created",
PAYMENT_COMPLETED: "swap.payment_completed",
PAYMENT_CAPTURED: "swap.payment_captured",
@@ -28,6 +29,7 @@ class SwapService extends BaseService {
paymentProviderService,
shippingOptionService,
fulfillmentService,
orderService,
}) {
super()
@@ -55,6 +57,9 @@ class SwapService extends BaseService {
/** @private @const {FulfillmentService} */
this.fulfillmentService_ = fulfillmentService
/** @private @const {OrderService} */
this.orderService_ = orderService
/** @private @const {ShippingOptionService} */
this.shippingOptionService_ = shippingOptionService
@@ -77,6 +82,7 @@ class SwapService extends BaseService {
lineItemService: this.lineItemService_,
paymentProviderService: this.paymentProviderService_,
shippingOptionService: this.shippingOptionService_,
orderService: this.orderService_,
fulfillmentService: this.fulfillmentService_,
})
@@ -239,14 +245,12 @@ class SwapService extends BaseService {
const result = await swapRepo.save(created)
await this.returnService_.withTransaction(manager).create(
{
swap_id: result.id,
items: returnItems,
shipping_method: returnShipping,
},
order
)
await this.returnService_.withTransaction(manager).create({
swap_id: result.id,
order_id: order.id,
items: returnItems,
shipping_method: returnShipping,
})
await this.eventBus_
.withTransaction(manager)
@@ -273,6 +277,10 @@ class SwapService extends BaseService {
const swapRepo = manager.getCustomRepository(this.swapRepository_)
if (swap.difference_due < 0) {
if (swap.payment_status === "difference_refunded") {
return swap
}
try {
await this.paymentProviderService_
.withTransaction(manager)
@@ -299,6 +307,10 @@ class SwapService extends BaseService {
.emit(SwapService.Events.REFUND_PROCESSED, result)
return result
} else if (swap.difference_due === 0) {
if (swap.payment_status === "difference_refunded") {
return swap
}
swap.payment_status = "difference_refunded"
const result = await swapRepo.save(swap)
@@ -309,6 +321,10 @@ class SwapService extends BaseService {
}
try {
if (swap.payment_status === "captured") {
return swap
}
await this.paymentProviderService_
.withTransaction(manager)
.capturePayment(swap.payment)
@@ -365,6 +381,8 @@ class SwapService extends BaseService {
relations: [
"order",
"order.items",
"order.swaps",
"order.swaps.additional_items",
"order.discounts",
"additional_items",
"return_order",
@@ -419,7 +437,15 @@ class SwapService extends BaseService {
}
for (const r of swap.return_order.items) {
const lineItem = order.items.find(i => i.id === r.item_id)
let allItems = [...order.items]
if (order.swaps && order.swaps.length) {
for (const s of order.swaps) {
allItems = [...allItems, ...s.additional_items]
}
}
const lineItem = allItems.find(i => i.id === r.item_id)
const toCreate = {
cart_id: cart.id,
@@ -499,6 +525,7 @@ class SwapService extends BaseService {
.withTransaction(manager)
.updatePayment(payment.id, {
swap_id: swapId,
order_id: swap.order_id,
})
}
@@ -720,37 +747,6 @@ class SwapService extends BaseService {
})
}
/**
* Dedicated method to set metadata for an order.
* To ensure that plugins does not overwrite each
* others metadata fields, setMetadata is provided.
* @param {string} orderId - the order to decorate.
* @param {string} key - key for metadata field
* @param {string} value - value for metadata field.
* @return {Promise} resolves to the updated result.
*/
setMetadata_(swap, metadata) {
const existing = swap.metadata || {}
const newData = {}
for (const [key, value] of Object.entries(metadata)) {
if (typeof key !== "string") {
throw new MedusaError(
MedusaError.Types.INVALID_ARGUMENT,
"Key type is invalid. Metadata keys must be strings"
)
}
newData[key] = value
}
const updated = {
...existing,
...newData,
}
return updated
}
/**
* Dedicated method to delete metadata for a swap.
* @param {string} swapId - the order to delete metadata from.
@@ -774,6 +770,39 @@ class SwapService extends BaseService {
throw new MedusaError(MedusaError.Types.DB_ERROR, err.message)
})
}
/**
* Registers the swap return items as received so that they cannot be used
* as a part of other swaps/returns.
* @param {string} id - the id of the order with the swap.
* @param {string} swapId - the id of the swap that has been received.
* @returns {Promise<Order>} the resulting order
*/
async registerReceived(id) {
return this.atomicPhase_(async manager => {
const swap = await this.retrieve(id, {
relations: ["return_order", "return_order.items"],
})
if (swap.return_order.status !== "received") {
throw new MedusaError(
MedusaError.Types.NOT_ALLOWED,
"Swap is not received"
)
}
const result = await this.retrieve(id)
await this.eventBus_
.withTransaction(manager)
.emit(SwapService.Events.RECEIVED, {
id: id,
order_id: result.order_id,
})
return result
})
}
}
export default SwapService

View File

@@ -26,6 +26,26 @@ class TotalsService extends BaseService {
return subtotal + taxTotal + shippingTotal - discountTotal - giftCardTotal
}
getPaidTotal(order) {
const total = order.payments?.reduce((acc, next) => {
acc += next.amount
return acc
}, 0)
return total
}
getSwapTotal(order) {
let swapTotal = 0
if (order.swaps && order.swaps.length) {
for (const s of order.swaps) {
swapTotal = swapTotal + s.difference_due
}
}
return swapTotal
}
/**
* Calculates subtotal of a given cart or order.
* @param {Cart || Order} object - cart or order to calculate subtotal for
@@ -128,7 +148,16 @@ class TotalsService extends BaseService {
* @return {int} the calculated subtotal
*/
getRefundTotal(order, lineItems) {
const itemIds = order.items.map(i => i.id)
let itemIds = order.items.map(i => i.id)
// in case we swap a swap, we need to include swap items
if (order.swaps && order.swaps.length) {
for (const s of order.swaps) {
const swapItemIds = s.additional_items.map(el => el.id)
itemIds = [...itemIds, ...swapItemIds]
}
}
const refunds = lineItems.map(i => {
if (!itemIds.includes(i.id)) {
throw new MedusaError(
@@ -213,6 +242,16 @@ class TotalsService extends BaseService {
getLineDiscounts(cart, discount) {
const subtotal = this.getSubtotal(cart, { excludeNonDiscounts: true })
let merged = [...cart.items]
// merge items from order with items from order swaps
if (cart.swaps && cart.swaps.length) {
for (const s of cart.swaps) {
merged = [...merged, ...s.additional_items]
}
}
const { type, allocation, value } = discount.rule
if (allocation === "total") {
let percentage = 0
@@ -225,7 +264,7 @@ class TotalsService extends BaseService {
percentage = nominator / subtotal
}
return cart.items.map(item => {
return merged.map(item => {
const lineTotal = item.unit_price * item.quantity
return {
@@ -239,7 +278,7 @@ class TotalsService extends BaseService {
cart,
type
)
return cart.items.map(item => {
return merged.map(item => {
const discounted = allocationDiscounts.find(
a => a.lineItem.id === item.id
)
@@ -250,7 +289,7 @@ class TotalsService extends BaseService {
})
}
return cart.items.map(i => ({ item: i, amount: 0 }))
return merged.map(i => ({ item: i, amount: 0 }))
}
getGiftCardTotal(cart) {