feature: swaps (#137)

* feat: swap schema

* chore(refactor): move returns to separate service

* swap order creates return

* fix: adds returns/fulfillments/shipments and cart creation to swaps

* search products by variants

* feat: swaps ready

* fix: test passing

* chore: include customer expand fields on order

* add tests

* fix: add brightpearl plugin support

* fix: adds swap link template setting

* fix: tests

* fix(medusa-plugin-brightpearl): add swap status option

* docs: add swap description

* fix: adds GET swap in store api

* chore: unmock axios

* fix: passing klarna tests

* fix: pr
This commit is contained in:
Sebastian Rindom authored and GitHub committed 2020-11-24 14:02:49 +01:00
1 parent c6996dd530
commit fd04233238
56 files changed
+3266 -564

No files matched your search

@@ -1,6 +1,7 @@
export default async (req, res) => {
const { id } = req.params
try {
const orderService = req.scope.resolve("orderService")
const customerService = req.scope.resolve("customerService")
let customer = await customerService.retrieve(id)
customer = await customerService.decorate(
@@ -12,9 +13,14 @@ export default async (req, res) => {
"shipping_addresses",
"phone",
],
["orders"]
[]
)
customer.orders = await Promise.all(customer.orders.map(async oId => {
const order = await orderService.retrieve(oId)
return orderService.decorate(order, [], [])
}))
res.json({ customer })
} catch (err) {
throw err
@@ -16,11 +16,18 @@ export default async (req, res) => {
}
try {
const orderService = req.scope.resolve("orderService")
const customerService = req.scope.resolve("customerService")
await customerService.update(id, value)
const customer = await customerService.retrieve(id)
const data = await customerService.decorate(customer)
data.orders = await Promise.all(
customer.orders.map(async oId => {
const order = await orderService.retrieve(oId)
return orderService.decorate(order, [], [])
})
)
res.status(200).json({ customer: data })
} catch (err) {
throw err
@@ -4,7 +4,11 @@ export default async (req, res) => {
try {
const orderService = req.scope.resolve("orderService")
let order = await orderService.archive(id)
order = await orderService.decorate(order, [], ["region"])
order = await orderService.decorate(
order,
[],
["region", "customer", "swaps"]
)
res.json({ order })
} catch (error) {
throw error
@@ -4,7 +4,11 @@ export default async (req, res) => {
try {
const orderService = req.scope.resolve("orderService")
let order = await orderService.cancel(id)
order = await orderService.decorate(order, [], ["region"])
order = await orderService.decorate(
order,
[],
["region", "customer", "swaps"]
)
res.json({ order })
} catch (error) {
throw error
@@ -4,7 +4,11 @@ export default async (req, res) => {
try {
const orderService = req.scope.resolve("orderService")
let order = await orderService.capturePayment(id)
order = await orderService.decorate(order, [], ["region"])
order = await orderService.decorate(
order,
[],
["region", "customer", "swaps"]
)
res.json({ order })
} catch (error) {
throw error
@@ -0,0 +1,23 @@
export default async (req, res) => {
const { id, swap_id } = req.params
try {
const orderService = req.scope.resolve("orderService")
const swapService = req.scope.resolve("swapService")
const order = await orderService.retrieve(id)
await swapService.capturePayment(swap_id)
// Decorate the order
const data = await orderService.decorate(
order,
[],
["region", "customer", "swaps"]
)
res.json({ order: data })
} catch (error) {
throw error
}
}
@@ -4,7 +4,7 @@ export default async (req, res) => {
try {
const orderService = req.scope.resolve("orderService")
let order = await orderService.completeOrder(id)
order = await orderService.decorate(order, [], ["region"])
order = await orderService.decorate(order, [], ["region", "customer", "swaps"])
res.json({ order })
} catch (error) {
console.log(error)
@@ -26,8 +26,14 @@ export default async (req, res) => {
value.items,
value.metadata
)
order = await orderService.decorate(order, [], ["region"])
res.json({ order })
const data = await orderService.decorate(
order,
[],
["region", "customer", "swaps"]
)
res.json({ order: data })
} catch (error) {
throw error
}
@@ -22,7 +22,11 @@ export default async (req, res) => {
value.fulfillment_id,
value.tracking_numbers
)
order = await orderService.decorate(order, [], ["region"])
order = await orderService.decorate(
order,
[],
["region", "customer", "swaps"]
)
res.json({ order })
} catch (error) {
throw error
@@ -0,0 +1,41 @@
import { MedusaError, Validator } from "medusa-core-utils"
export default async (req, res) => {
const { id, swap_id } = req.params
const schema = Validator.object().keys({
fulfillment_id: Validator.string().required(),
tracking_numbers: Validator.array()
.items(Validator.string())
.optional(),
})
const { value, error } = schema.validate(req.body)
if (error) {
throw new MedusaError(MedusaError.Types.INVALID_DATA, error.details)
}
try {
const orderService = req.scope.resolve("orderService")
const swapService = req.scope.resolve("swapService")
const order = await orderService.retrieve(id)
await swapService.createShipment(
swap_id,
value.fulfillment_id,
value.tracking_numbers
)
// Decorate the order
const data = await orderService.decorate(
order,
[],
["region", "customer", "swaps"]
)
res.json({ order: data })
} catch (error) {
throw error
}
}
@@ -0,0 +1,67 @@
import { MedusaError, Validator } from "medusa-core-utils"
export default async (req, res) => {
const { id } = req.params
const schema = Validator.object().keys({
return_items: Validator.array()
.items({
item_id: Validator.string().required(),
quantity: Validator.number().required(),
})
.required(),
return_shipping: Validator.object()
.keys({
id: Validator.string().optional(),
price: Validator.number().optional(),
})
.optional(),
additional_items: Validator.array().items({
variant_id: Validator.string().required(),
quantity: Validator.number().required(),
}),
})
const { value, error } = schema.validate(req.body)
if (error) {
throw new MedusaError(MedusaError.Types.INVALID_DATA, error.details)
}
try {
const orderService = req.scope.resolve("orderService")
const swapService = req.scope.resolve("swapService")
let order = await orderService.retrieve(id)
// Phase 1: Create swap and add it to the order
const swap = await swapService.create(
order,
value.return_items,
value.additional_items,
value.return_shipping
)
await orderService.registerSwapCreated(id, swap._id)
// --> swap_created
// Phase 2: Create a return request from the swap
await swapService.requestReturn(order, swap._id)
// --> return_request_created
// Phase 3: Create a cart that can be used to pay for the swap difference
await swapService.createCart(order, swap._id)
// --> finished
order = await orderService.retrieve(id)
const data = await orderService.decorate(
order,
[],
["region", "customer", "swaps"]
)
res.status(200).json({ order: data })
} catch (err) {
throw err
}
}
@@ -0,0 +1,36 @@
import { MedusaError, Validator } from "medusa-core-utils"
export default async (req, res) => {
const { id, swap_id } = req.params
const schema = Validator.object().keys({
metadata: Validator.object().optional(),
})
const { value, error } = schema.validate(req.body)
if (error) {
throw new MedusaError(MedusaError.Types.INVALID_DATA, error.details)
}
try {
const orderService = req.scope.resolve("orderService")
const swapService = req.scope.resolve("swapService")
// Fetch the order
const order = await orderService.retrieve(id)
// Receive the return
await swapService.createFulfillment(order, swap_id, value.metadata)
// Decorate the order
const data = await orderService.decorate(
order,
[],
["region", "customer", "swaps"]
)
res.status(200).json({ order: data })
} catch (err) {
throw err
}
}
@@ -6,11 +6,11 @@ export default async (req, res) => {
const customerService = req.scope.resolve("customerService")
let order = await orderService.retrieve(id)
order = await orderService.decorate(order, [], ["region", "customer"])
if (order.customer_id) {
order.customer = await customerService.retrieve(order.customer_id)
}
order = await orderService.decorate(
order,
[],
["region", "customer", "swaps"]
)
res.json({ order })
} catch (error) {
@@ -95,6 +95,43 @@ export default app => {
middlewares.wrap(require("./archive-order").default)
)
/**
* Creates a swap, requests a return and prepares a cart for payment.
*/
route.post("/:id/swaps", middlewares.wrap(require("./create-swap").default))
/**
* Receives the inventory to return from a swap
*/
route.post(
"/:id/swaps/:swap_id/receive",
middlewares.wrap(require("./receive-swap").default)
)
/**
* Fulfills a swap.
*/
route.post(
"/:id/swaps/:swap_id/fulfillments",
middlewares.wrap(require("./fulfill-swap").default)
)
/**
* Marks a swap fulfillment as shipped.
*/
route.post(
"/:id/swaps/:swap_id/shipments",
middlewares.wrap(require("./create-swap-shipment").default)
)
/**
* Captures the payment associated with a swap
*/
route.post(
"/:id/swaps/:swap_id/capture",
middlewares.wrap(require("./capture-swap").default)
)
/**
* Set metadata key / value pair.
*/
@@ -25,14 +25,18 @@ export default async (req, res) => {
if (typeof value.refund !== "undefined" && value.refund < 0) {
refundAmount = 0
}
let order = await orderService.return(
let order = await orderService.receiveReturn(
id,
return_id,
value.items,
refundAmount,
true
)
order = await orderService.decorate(order, [], ["region"])
order = await orderService.decorate(
order,
[],
["region", "customer", "swaps"]
)
res.status(200).json({ order })
} catch (err) {
@@ -0,0 +1,44 @@
import { MedusaError, Validator } from "medusa-core-utils"
export default async (req, res) => {
const { id, swap_id } = req.params
const schema = Validator.object().keys({
items: Validator.array()
.items({
item_id: Validator.string().required(),
quantity: Validator.number().required(),
})
.required(),
})
const { value, error } = schema.validate(req.body)
if (error) {
throw new MedusaError(MedusaError.Types.INVALID_DATA, error.details)
}
try {
const orderService = req.scope.resolve("orderService")
const swapService = req.scope.resolve("swapService")
// Fetch the order
let order = await orderService.retrieve(id)
// Receive the return
await swapService.receiveReturn(order, swap_id, value.items)
// Register swap reception
order = await orderService.registerSwapReceived(id, swap_id)
// Decorate the order
const data = await orderService.decorate(
order,
[],
["region", "customer", "swaps"]
)
res.status(200).json({ order: data })
} catch (err) {
throw err
}
}
@@ -23,7 +23,11 @@ export default async (req, res) => {
value.reason,
value.note
)
order = await orderService.decorate(order, [], ["region"])
order = await orderService.decorate(
order,
[],
["region", "customer", "swaps"]
)
res.status(200).json({ order })
} catch (err) {
@@ -66,7 +66,11 @@ export default async (req, res) => {
)
}
order = await orderService.decorate(order, [], ["region"])
order = await orderService.decorate(
order,
[],
["region", "customer", "swaps"]
)
res.status(200).json({ order })
} catch (err) {
@@ -16,7 +16,11 @@ export default async (req, res) => {
try {
const orderService = req.scope.resolve("orderService")
let order = await orderService.setMetadata(id, value.key, value.value)
order = await orderService.decorate(order, [], ["region"])
order = await orderService.decorate(
order,
[],
["region", "customer", "swaps"]
)
res.status(200).json({ order })
} catch (err) {
@@ -32,7 +32,11 @@ export default async (req, res) => {
try {
const orderService = req.scope.resolve("orderService")
let order = await orderService.update(id, value)
order = await orderService.decorate(order, [], ["region"])
order = await orderService.decorate(
order,
[],
["region", "customer", "swaps"]
)
res.status(200).json({ order })
} catch (err) {
@@ -2,6 +2,7 @@ import _ from "lodash"
export default async (req, res) => {
try {
const variantService = req.scope.resolve("productVariantService")
const productService = req.scope.resolve("productService")
const queryBuilderService = req.scope.resolve("queryBuilderService")
@@ -14,6 +15,22 @@ export default async (req, res) => {
query.is_giftcard = req.query.is_giftcard === "true"
}
let variantMatches = []
if ("q" in req.query) {
let textQ = req.query.q
variantMatches = await variantService.list({
$or: [
{ sku: new RegExp(textQ, "i") },
{ title: new RegExp(textQ, "i") },
],
})
query.$or = [
...query.$or,
{ variants: { $in: variantMatches.map(({ _id }) => _id.toString()) } },
]
}
const limit = parseInt(req.query.limit) || 0
const offset = parseInt(req.query.offset) || 0
@@ -3,6 +3,7 @@ import { MedusaError, Validator } from "medusa-core-utils"
export default async (req, res) => {
const schema = Validator.object().keys({
name: Validator.string(),
swap_link_template: Validator.string(),
default_currency: Validator.string(),
currencies: Validator.array().items(Validator.string()),
})
@@ -10,6 +10,7 @@ import orderRoutes from "./orders"
import customerRoutes from "./customers"
import shippingOptionRoutes from "./shipping-options"
import regionRoutes from "./regions"
import swapRoutes from "./swaps"
const route = Router()
@@ -33,6 +34,7 @@ export default (app, container, config) => {
cartRoutes(route, container)
shippingOptionRoutes(route)
regionRoutes(route)
swapRoutes(route)
return app
}
@@ -0,0 +1,26 @@
import { MedusaError, Validator } from "medusa-core-utils"
export default async (req, res) => {
const schema = Validator.object().keys({
cart_id: Validator.string().required(),
})
const { value, error } = schema.validate(req.body)
if (error) {
throw new MedusaError(MedusaError.Types.INVALID_DATA, error.details)
}
const swapService = req.scope.resolve("swapService")
try {
const swap = await swapService.retrieveByCartId(value.cart_id)
const data = await swapService.registerCartCompletion(
swap._id,
value.cart_id
)
res.status(200).json({ swap: data })
} catch (err) {
throw err
}
}
@@ -0,0 +1,11 @@
export default async (req, res) => {
const { cart_id } = req.params
try {
const swapService = req.scope.resolve("swapService")
const swap = await swapService.retrieveByCartId(cart_id)
res.json({ swap })
} catch (error) {
throw error
}
}
@@ -0,0 +1,16 @@
import { Router } from "express"
import middlewares from "../../../middlewares"
const route = Router()
export default app => {
app.use("/swaps", route)
route.get(
"/:cart_id",
middlewares.wrap(require("./get-swap-by-cart").default)
)
route.post("/", middlewares.wrap(require("./create-swap").default))
return app
}
@@ -74,6 +74,7 @@ export const orders = {
processedOrder: {
_id: IdMap.getId("processed-order"),
email: "oliver@test.dk",
tax_rate: 0,
billing_address: {
first_name: "Oli",
last_name: "Medusa",
+2
View File
@@ -29,6 +29,8 @@ class CartModel extends BaseModel {
shipping_options: { type: [ShippingMethodSchema], default: [] },
payment_method: { type: PaymentMethodSchema },
shipping_methods: { type: [ShippingMethodSchema], default: [] },
is_swap: { type: Boolean, default: false },
created: { type: String, default: Date.now },
metadata: { type: mongoose.Schema.Types.Mixed, default: {} },
}
}
+3 -3
View File
@@ -6,7 +6,6 @@ import PaymentMethodSchema from "./schemas/payment-method"
import ShippingMethodSchema from "./schemas/shipping-method"
import AddressSchema from "./schemas/address"
import DiscountSchema from "./schemas/discount"
import ShipmentSchema from "./schemas/shipment"
import ReturnSchema from "./schemas/return"
import RefundSchema from "./schemas/refund"
import FulfillmentSchema from "./schemas/fulfillment"
@@ -35,8 +34,9 @@ class OrderModel extends BaseModel {
region_id: { type: String, required: true },
discounts: { type: [DiscountSchema], default: [] },
customer_id: { type: String },
payment_method: { type: PaymentMethodSchema, default: {} },
shipping_methods: { type: [ShippingMethodSchema], default: [] },
payment_method: { type: PaymentMethodSchema, required: true },
shipping_methods: { type: [ShippingMethodSchema], required: true },
swaps: { type: [String], default: [] },
documents: { type: [String], default: [] },
created: { type: String, default: Date.now },
metadata: { type: mongoose.Schema.Types.Mixed, default: {} },
@@ -13,7 +13,7 @@ import mongoose from "mongoose"
* @property {Object} metadata
*/
export default new mongoose.Schema({
item_id: { type: String, required: true, unique: true },
item_id: { type: String, required: true },
content: { type: mongoose.Schema.Types.Mixed, required: true },
quantity: { type: Number, required: true },
is_requested: { type: Boolean, required: true },
+1
View File
@@ -9,6 +9,7 @@ class StoreModel extends BaseModel {
currencies: { type: [String], default: [] },
payment_providers: { type: [String], default: [] },
fulfillment_providers: { type: [String], default: [] },
swap_link_template: { type: String },
metadata: { type: mongoose.Schema.Types.Mixed, default: {} },
}
}
+35
View File
@@ -0,0 +1,35 @@
import mongoose from "mongoose"
import { BaseModel } from "medusa-interfaces"
import LineItemSchema from "./schemas/line-item"
import ReturnSchema from "./schemas/return"
import FulfillmentSchema from "./schemas/fulfillment"
import PaymentMethodSchema from "./schemas/payment-method"
import ShippingMethodSchema from "./schemas/shipping-method"
import AddressSchema from "./schemas/address"
class SwapModel extends BaseModel {
static modelName = "Swap"
static schema = {
fulfillment_status: { type: String, default: "not_fulfilled" },
payment_status: { type: String, default: "awaiting" },
is_paid: { type: Boolean, default: false },
return: { type: ReturnSchema },
return_items: { type: [mongoose.Schema.Types.Mixed], required: true },
return_shipping: { type: mongoose.Schema.Types.Mixed },
fulfillments: { type: [FulfillmentSchema] },
additional_items: { type: [LineItemSchema], required: true },
payment_method: { type: PaymentMethodSchema },
shipping_methods: { type: [ShippingMethodSchema] },
shipping_address: { type: AddressSchema },
amount_paid: { type: Number },
region_id: { type: String },
currency_code: { type: String },
order_id: { type: String },
cart_id: { type: String },
created: { type: String, default: Date.now },
metadata: { type: mongoose.Schema.Types.Mixed, default: {} },
}
}
export default SwapModel
@@ -115,6 +115,7 @@ export const orders = {
profile_id: IdMap.getId("profile1"),
},
],
tax_rate: 0,
fulfillment_status: "fulfilled",
payment_status: "captured",
status: "completed",
@@ -148,6 +148,58 @@ describe("CartService", () => {
region_id: IdMap.getId("testRegion"),
})
})
it("creates a cart with a prefilled shipping address", async () => {
const res = cartService.create({
region_id: IdMap.getId("testRegion"),
shipping_address: {
first_name: "LeBron",
last_name: "James",
address_1: "Dunk St",
city: "Dunkville",
province: "CA",
postal_code: "12345",
country_code: "PT",
},
})
await expect(res).rejects.toThrow("Shipping country not in region")
})
it("creates a cart with a prefilled shipping address", async () => {
await cartService.create({
region_id: IdMap.getId("testRegion"),
shipping_address: {
first_name: "LeBron",
last_name: "James",
address_1: "Dunk St",
city: "Dunkville",
province: "CA",
postal_code: "12345",
country_code: "US",
},
})
expect(EventBusServiceMock.emit).toHaveBeenCalledTimes(1)
expect(EventBusServiceMock.emit).toHaveBeenCalledWith(
"cart.created",
expect.any(Object)
)
expect(CartModelMock.create).toHaveBeenCalledTimes(1)
expect(CartModelMock.create).toHaveBeenCalledWith({
region_id: IdMap.getId("testRegion"),
shipping_address: {
first_name: "LeBron",
last_name: "James",
address_1: "Dunk St",
city: "Dunkville",
province: "CA",
postal_code: "12345",
country_code: "US",
},
})
})
})
describe("addLineItem", () => {
+251 -43
View File
@@ -2,6 +2,8 @@ import { IdMap } from "medusa-test-utils"
import { OrderModelMock, orders } from "../../models/__mocks__/order"
import { carts } from "../../models/__mocks__/cart"
import OrderService from "../order"
import ReturnService from "../return"
import FulfillmentService from "../fulfillment"
import {
PaymentProviderServiceMock,
DefaultProviderMock,
@@ -434,11 +436,15 @@ describe("OrderService", () => {
})
describe("createFulfillment", () => {
const fulfillmentService = new FulfillmentService({
fulfillmentProviderService: FulfillmentProviderServiceMock,
shippingProfileService: ShippingProfileServiceMock,
totalsService: TotalsServiceMock,
})
const orderService = new OrderService({
orderModel: OrderModelMock,
paymentProviderService: PaymentProviderServiceMock,
fulfillmentProviderService: FulfillmentProviderServiceMock,
shippingProfileService: ShippingProfileServiceMock,
fulfillmentService,
eventBusService: EventBusServiceMock,
})
@@ -509,8 +515,7 @@ describe("OrderService", () => {
},
quantity: 1,
},
fulfilled_quantity: 10,
fulfilled: true,
fulfilled_quantity: 0,
quantity: 10,
},
],
@@ -560,9 +565,17 @@ describe("OrderService", () => {
})
})
describe("return", () => {
describe("receiveReturn", () => {
const returnService = new ReturnService({
totalsService: TotalsServiceMock,
shippingOptionService: ShippingOptionServiceMock,
fulfillmentProviderService: FulfillmentProviderServiceMock,
})
const orderService = new OrderService({
orderModel: OrderModelMock,
returnService,
shippingOptionService: ShippingOptionServiceMock,
fulfillmentProviderService: FulfillmentProviderServiceMock,
paymentProviderService: PaymentProviderServiceMock,
totalsService: TotalsServiceMock,
eventBusService: EventBusServiceMock,
@@ -573,7 +586,7 @@ describe("OrderService", () => {
})
it("calls order model functions", async () => {
await orderService.return(
await orderService.receiveReturn(
IdMap.getId("returned-order"),
IdMap.getId("return"),
[
@@ -671,7 +684,7 @@ describe("OrderService", () => {
})
it("return with custom refund", async () => {
await orderService.return(
await orderService.receiveReturn(
IdMap.getId("returned-order"),
IdMap.getId("return"),
[
@@ -770,7 +783,7 @@ describe("OrderService", () => {
})
it("calls order model functions and sets partially_returned", async () => {
await orderService.return(
await orderService.receiveReturn(
IdMap.getId("order-refund"),
IdMap.getId("return"),
[
@@ -882,7 +895,7 @@ describe("OrderService", () => {
})
it("sets requires_action on additional items", async () => {
await orderService.return(
await orderService.receiveReturn(
IdMap.getId("order-refund"),
IdMap.getId("return"),
[
@@ -930,17 +943,17 @@ describe("OrderService", () => {
expect(OrderModelMock.updateOne).toHaveBeenCalledTimes(1)
expect(OrderModelMock.updateOne).toHaveBeenCalledWith(
{ _id: IdMap.getId("order-refund") },
{ _id: IdMap.getId("order-refund"), "returns._id": originalReturn._id },
{
$set: {
returns: [toSet],
"returns.$": toSet,
},
}
)
})
it("sets requires_action on unmatcing quantities", async () => {
await orderService.return(
await orderService.receiveReturn(
IdMap.getId("order-refund"),
IdMap.getId("return"),
[
@@ -966,10 +979,10 @@ describe("OrderService", () => {
expect(OrderModelMock.updateOne).toHaveBeenCalledTimes(1)
expect(OrderModelMock.updateOne).toHaveBeenCalledWith(
{ _id: IdMap.getId("order-refund") },
{ _id: IdMap.getId("order-refund"), "returns._id": originalReturn._id },
{
$set: {
returns: [toSet],
"returns.$": toSet,
},
}
)
@@ -977,8 +990,14 @@ describe("OrderService", () => {
})
describe("requestReturn", () => {
const returnService = new ReturnService({
totalsService: TotalsServiceMock,
shippingOptionService: ShippingOptionServiceMock,
fulfillmentProviderService: FulfillmentProviderServiceMock,
})
const orderService = new OrderService({
orderModel: OrderModelMock,
returnService,
shippingOptionService: ShippingOptionServiceMock,
fulfillmentProviderService: FulfillmentProviderServiceMock,
paymentProviderService: PaymentProviderServiceMock,
@@ -1110,9 +1129,15 @@ describe("OrderService", () => {
})
describe("createShipment", () => {
const fulfillmentService = new FulfillmentService({
fulfillmentProviderService: FulfillmentProviderServiceMock,
shippingProfileService: ShippingProfileServiceMock,
totalsService: TotalsServiceMock,
})
const orderService = new OrderService({
orderModel: OrderModelMock,
fulfillmentProviderService: FulfillmentProviderServiceMock,
fulfillmentService,
eventBusService: EventBusServiceMock,
})
@@ -1136,37 +1161,34 @@ describe("OrderService", () => {
},
{
$set: {
"fulfillments.$": [
{
_id: IdMap.getId("fulfillment"),
provider_id: "default_provider",
tracking_numbers: ["1234", "2345"],
data: {},
items: [
{
_id: IdMap.getId("existingLine"),
content: {
product: {
_id: IdMap.getId("validId"),
},
quantity: 1,
unit_price: 123,
variant: {
_id: IdMap.getId("can-cover"),
},
"fulfillments.$": {
_id: IdMap.getId("fulfillment"),
provider_id: "default_provider",
tracking_numbers: ["1234", "2345"],
data: {},
items: [
{
_id: IdMap.getId("existingLine"),
content: {
product: {
_id: IdMap.getId("validId"),
},
quantity: 1,
unit_price: 123,
variant: {
_id: IdMap.getId("can-cover"),
},
description: "This is a new line",
fulfilled_quantity: 10,
shipped_quantity: 10,
quantity: 10,
thumbnail: "test-img-yeah.com/thumb",
title: "merge line",
},
],
shipped_at: expect.anything(),
metadata: {},
},
],
description: "This is a new line",
fulfilled_quantity: 10,
quantity: 10,
thumbnail: "test-img-yeah.com/thumb",
title: "merge line",
},
],
shipped_at: expect.anything(),
metadata: {},
},
items: [
{
_id: IdMap.getId("existingLine"),
@@ -1181,6 +1203,7 @@ describe("OrderService", () => {
},
},
description: "This is a new line",
shipped: true,
fulfilled_quantity: 10,
shipped_quantity: 10,
quantity: 10,
@@ -1203,6 +1226,191 @@ describe("OrderService", () => {
})
})
describe("registerSwapCreated", () => {
beforeEach(async () => {
jest.clearAllMocks()
})
const orderModel = {
findOne: jest
.fn()
.mockReturnValue(Promise.resolve({ _id: IdMap.getId("order") })),
updateOne: jest.fn().mockReturnValue(Promise.resolve()),
}
it("adds a swap to an order", async () => {
const swapService = {
retrieve: jest
.fn()
.mockReturnValue(
Promise.resolve({ _id: "1235", order_id: IdMap.getId("order") })
),
}
const orderService = new OrderService({
swapService,
orderModel,
eventBusService: { emit: jest.fn().mockReturnValue(Promise.resolve()) },
})
const res = orderService.registerSwapCreated(IdMap.getId("order"), "1235")
expect(res).resolves
await res
expect(orderModel.updateOne).toHaveBeenCalledWith(
{
_id: IdMap.getId("order"),
},
{
$addToSet: { swaps: "1235" },
}
)
})
it("fails if order/swap relationship is not satisfied", async () => {
const swapService = {
retrieve: jest
.fn()
.mockReturnValue(
Promise.resolve({ _id: "1235", order_id: IdMap.getId("order_1") })
),
}
const orderService = new OrderService({
swapService,
orderModel,
eventBusService: { emit: jest.fn().mockReturnValue(Promise.resolve()) },
})
const res = orderService.registerSwapCreated(IdMap.getId("order"), "1235")
expect(res).rejects.toThrow("Swap must belong to the given order")
})
})
describe("registerSwapReceived", () => {
beforeEach(async () => {
jest.clearAllMocks()
})
const orderModel = {
findOne: jest
.fn()
.mockReturnValue(Promise.resolve({ _id: IdMap.getId("order") })),
updateOne: jest.fn().mockReturnValue(Promise.resolve()),
}
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") })
),
}
const orderService = new OrderService({
swapService,
orderModel,
eventBusService: { emit: jest.fn().mockReturnValue(Promise.resolve()) },
})
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: { status: "requested" },
})
),
}
const orderService = new OrderService({
swapService,
orderModel,
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 model = {
findOne: jest.fn().mockReturnValue(
Promise.resolve({
_id: IdMap.getId("order_123"),
items: [
{
_id: IdMap.getId("1234"),
returned_quantity: 0,
quantity: 1,
},
],
})
),
updateOne: jest.fn().mockReturnValue(Promise.resolve()),
}
const swapService = {
retrieve: jest.fn().mockReturnValue(
Promise.resolve({
_id: "1235",
order_id: IdMap.getId("order_123"),
return: { status: "received" },
return_items: [{ item_id: IdMap.getId("1234"), quantity: 1 }],
})
),
}
const orderService = new OrderService({
swapService,
orderModel: model,
eventBusService: { emit: jest.fn().mockReturnValue(Promise.resolve()) },
})
await orderService.registerSwapReceived(IdMap.getId("order"), "1235")
expect(model.updateOne).toHaveBeenCalledWith(
{
_id: IdMap.getId("order_123"),
},
{
$set: {
items: [
{
_id: IdMap.getId("1234"),
returned_quantity: 1,
returned: true,
quantity: 1,
},
],
},
}
)
})
it("fails if order/swap relationship is not satisfied", async () => {
const swapService = {
retrieve: jest
.fn()
.mockReturnValue(
Promise.resolve({ _id: "1235", order_id: IdMap.getId("order_1") })
),
}
const orderService = new OrderService({
swapService,
orderModel,
eventBusService: { emit: jest.fn().mockReturnValue(Promise.resolve()) },
})
const res = orderService.registerSwapCreated(IdMap.getId("order"), "1235")
expect(res).rejects.toThrow("Swap must belong to the given order")
})
})
describe("archive", () => {
const orderService = new OrderService({
orderModel: OrderModelMock,
@@ -0,0 +1,731 @@
import { IdMap } from "medusa-test-utils"
import { ProductVariantServiceMock } from "../__mocks__/product-variant"
import {
// FulfillmentProviderServiceMock,
DefaultProviderMock as FulfillmentProviderMock,
} from "../__mocks__/fulfillment-provider"
import SwapService from "../swap"
const generateOrder = (orderId, items, additional = {}) => {
return {
_id: IdMap.getId(orderId),
items: items.map(
({
id,
product_id,
variant_id,
fulfilled,
returned,
quantity,
price,
}) => ({
_id: IdMap.getId(id),
content: {
product: {
_id: IdMap.getId(product_id),
},
variant: {
_id: IdMap.getId(variant_id),
},
unit_price: price,
},
quantity,
fulfilled_quantity: fulfilled || 0,
returned_quantity: returned || 0,
})
),
...additional,
}
}
const testOrder = generateOrder(
"test",
[
{
id: "line",
product_id: "product",
variant_id: "variant",
price: 100,
quantity: 2,
fulfilled: 1,
},
],
{
fulfillment_status: "fulfilled",
payment_status: "captured",
currency_code: "DKK",
region_id: IdMap.getId("region"),
tax_rate: 0,
shipping_address: {
first_name: "test",
last_name: "testson",
address_1: "1800 test st",
city: "testville",
province: "test",
country_code: "us",
postal_code: "12345",
phone: "+18001231234",
},
}
)
const SwapModel = ({ create, updateOne, findOne } = {}) => {
return {
create: jest.fn().mockImplementation((...args) => {
if (create) {
return create(...args)
}
return Promise.resolve({ data: "swap" })
}),
updateOne: jest.fn().mockImplementation((...args) => {
if (updateOne) {
return updateOne(...args)
}
return Promise.resolve({ data: "swap" })
}),
findOne: jest.fn().mockImplementation((...args) => {
if (findOne) {
return findOne(...args)
}
return Promise.resolve({ data: "swap" })
}),
}
}
describe("SwapService", () => {
describe("validateReturnItems_", () => {
beforeEach(() => {
jest.clearAllMocks()
})
it("fails if item is returned", async () => {
const swapService = new SwapService({})
const res = () =>
swapService.validateReturnItems_(
{
items: [
{
_id: IdMap.getId("line1"),
quantity: 1,
returned_quantity: 1,
},
],
},
[{ item_id: IdMap.getId("line1"), quantity: 1 }]
)
expect(res).toThrow("Cannot return more items than have been ordered")
})
it("fails if item is returned", async () => {
const swapService = new SwapService({})
const res = () =>
swapService.validateReturnItems_(
{
items: [
{
_id: IdMap.getId("line1"),
quantity: 1,
returned_quantity: 1,
},
],
},
[{ item_id: IdMap.getId("line2"), quantity: 1 }]
)
expect(res).toThrow("Item does not exist on order")
})
it("successfully resolves", async () => {
const swapService = new SwapService({})
const res = swapService.validateReturnItems_(
{
items: [
{
_id: IdMap.getId("line1"),
quantity: 1,
returned_quantity: 0,
},
],
},
[{ item_id: IdMap.getId("line1"), quantity: 1 }]
)
expect(res).toEqual([{ item_id: IdMap.getId("line1"), quantity: 1 }])
})
})
describe("createCart", () => {
beforeEach(() => {
jest.clearAllMocks()
})
describe("success", () => {
const existing = {
_id: IdMap.getId("test-swap"),
order_id: IdMap.getId("test"),
return: {
_id: IdMap.getId("return-swap"),
test: "notreceived",
refund_amount: 11,
},
return_items: [{ item_id: IdMap.getId("line"), quantity: 1 }],
additional_items: [{ data: "lines" }],
other: "data",
}
const cartService = {
create: jest
.fn()
.mockReturnValue(Promise.resolve({ _id: IdMap.getId("swap-cart") })),
}
const swapModel = SwapModel({ findOne: () => Promise.resolve(existing) })
const swapService = new SwapService({
productVariantService: ProductVariantServiceMock,
swapModel,
cartService,
})
it("finds swap and calls return create cart", async () => {
await swapService.createCart(testOrder, IdMap.getId("swap-1"))
expect(swapModel.findOne).toHaveBeenCalledTimes(1)
expect(swapModel.findOne).toHaveBeenCalledWith({
_id: IdMap.getId("swap-1"),
})
expect(cartService.create).toHaveBeenCalledTimes(1)
expect(cartService.create).toHaveBeenCalledWith({
email: testOrder.email,
shipping_address: testOrder.shipping_address,
billing_address: testOrder.billing_address,
items: [
{
_id: IdMap.getId("line"),
content: {
variant: {
_id: IdMap.getId("variant"),
},
product: {
_id: IdMap.getId("product"),
},
unit_price: -100,
},
quantity: 1,
fulfilled_quantity: 1,
returned_quantity: 0,
metadata: {
is_return_line: true,
},
},
...existing.additional_items,
],
region_id: testOrder.region_id,
customer_id: testOrder.customer_id,
is_swap: true,
metadata: {
swap_id: IdMap.getId("test-swap"),
parent_order_id: IdMap.getId("test"),
},
})
expect(swapModel.updateOne).toHaveBeenCalledTimes(1)
expect(swapModel.updateOne).toHaveBeenCalledWith(
{ _id: IdMap.getId("swap-1") },
{ $set: { cart_id: IdMap.getId("swap-cart") } }
)
})
})
describe("failure", () => {
const existing = {
return: {
_id: IdMap.getId("return-swap"),
test: "notreceived",
refund_amount: 11,
},
additional_items: [{ data: "lines" }],
other: "data",
}
it("fails if swap doesn't belong to order", async () => {
const swapModel = SwapModel({
findOne: () => Promise.resolve(existing),
})
const swapService = new SwapService({ swapModel })
const res = swapService.createCart(testOrder, IdMap.getId("swap-1"))
await expect(res).rejects.toThrow(
"The swap does not belong to the order"
)
})
it("fails if cart already created", async () => {
const swapModel = SwapModel({
findOne: () =>
Promise.resolve({
...existing,
order_id: IdMap.getId("test"),
cart_id: IdMap.getId("swap-cart"),
}),
})
const swapService = new SwapService({ swapModel })
const res = swapService.createCart(testOrder, IdMap.getId("swap-1"))
await expect(res).rejects.toThrow(
"A cart has already been created for the swap"
)
})
})
})
describe("create", () => {
beforeEach(() => {
jest.clearAllMocks()
})
describe("success", () => {
const lineItemService = {
generate: jest
.fn()
.mockImplementation((variantId, regionId, quantity, metadata) => {
return {
content: {
unit_price: 100,
variant: {
_id: variantId,
},
product: {
_id: IdMap.getId("product"),
},
quantity: 1,
},
quantity,
}
}),
}
const swapModel = SwapModel()
const swapService = new SwapService({
swapModel,
productVariantService: ProductVariantServiceMock,
lineItemService,
})
it("generates lines items", async () => {
await swapService.create(
testOrder,
[{ item_id: IdMap.getId("line"), quantity: 1 }],
[{ variant_id: IdMap.getId("new-variant"), quantity: 1 }],
{
id: IdMap.getId("return-shipping"),
price: 20,
}
)
expect(lineItemService.generate).toHaveBeenCalledTimes(1)
expect(lineItemService.generate).toHaveBeenCalledWith(
IdMap.getId("new-variant"),
IdMap.getId("region"),
1
)
})
it("creates swap", async () => {
await swapService.create(
testOrder,
[{ item_id: IdMap.getId("line"), quantity: 1 }],
[{ variant_id: IdMap.getId("new-variant"), quantity: 1 }],
{
id: IdMap.getId("return-shipping"),
price: 20,
}
)
expect(swapModel.create).toHaveBeenCalledWith({
order_id: IdMap.getId("test"),
return_items: [{ item_id: IdMap.getId("line"), quantity: 1 }],
region_id: IdMap.getId("region"),
currency_code: "DKK",
return_shipping: {
id: IdMap.getId("return-shipping"),
price: 20,
},
additional_items: [
{
content: {
unit_price: 100,
variant: {
_id: IdMap.getId("new-variant"),
},
product: {
_id: IdMap.getId("product"),
},
quantity: 1,
},
quantity: 1,
},
],
})
})
})
})
describe("receiveReturn", () => {
beforeEach(() => {
jest.clearAllMocks()
})
describe("success", () => {
const returnService = {
receiveReturn: jest
.fn()
.mockReturnValue(Promise.resolve({ test: "received" })),
}
const existing = {
order_id: IdMap.getId("test"),
return: {
_id: IdMap.getId("return-swap"),
test: "notreceived",
refund_amount: 11,
},
other: "data",
}
const swapModel = SwapModel({ findOne: () => Promise.resolve(existing) })
const swapService = new SwapService({ swapModel, returnService })
it("calls register return and updates return value", async () => {
await swapService.receiveReturn(testOrder, IdMap.getId("swap"), [
{ variant_id: IdMap.getId("1234"), quantity: 1 },
])
expect(swapModel.updateOne).toHaveBeenCalledWith(
{ _id: IdMap.getId("swap") },
{
$set: {
status: "received",
return: { test: "received" },
},
}
)
expect(returnService.receiveReturn).toHaveBeenCalledTimes(1)
expect(returnService.receiveReturn).toHaveBeenCalledWith(
testOrder,
existing.return,
[{ variant_id: IdMap.getId("1234"), quantity: 1 }],
11,
false
)
})
})
describe("failure", () => {
const returnService = {
receiveReturn: jest
.fn()
.mockReturnValue(Promise.resolve({ status: "requires_action" })),
}
const existing = {
order_id: IdMap.getId("test"),
return: {
_id: IdMap.getId("return-swap"),
test: "notreceived",
refund_amount: 11,
},
other: "data",
}
const swapModel = SwapModel({
findOne: t =>
Promise.resolve(t._id.equals(IdMap.getId("empty")) ? {} : existing),
})
const swapService = new SwapService({ swapModel, returnService })
it("fails if swap has no return request", async () => {
const res = swapService.receiveReturn(
testOrder,
IdMap.getId("empty"),
[]
)
await expect(res).rejects.toThrow("Swap has no return request")
})
it("sets requires action if return fails", async () => {
await swapService.receiveReturn(testOrder, IdMap.getId("swap"), [
{ variant_id: IdMap.getId("1234"), quantity: 1 },
])
expect(swapModel.updateOne).toHaveBeenCalledWith(
{
_id: IdMap.getId("swap"),
},
{
$set: {
status: "requires_action",
return: { status: "requires_action" },
},
}
)
expect(returnService.receiveReturn).toHaveBeenCalledTimes(1)
expect(returnService.receiveReturn).toHaveBeenCalledWith(
testOrder,
existing.return,
[{ variant_id: IdMap.getId("1234"), quantity: 1 }],
11,
false
)
})
})
})
describe("createFulfillment", () => {
beforeEach(() => {
jest.clearAllMocks()
})
describe("success", () => {
const fulfillmentService = {
createFulfillment: jest
.fn()
.mockReturnValue(Promise.resolve([{ data: "new" }])),
}
const existing = {
additional_items: [
{
_id: IdMap.getId("1234"),
quantity: 2,
},
],
shipping_methods: [{ method: "1" }],
return: {
_id: IdMap.getId("return-swap"),
test: "notreceived",
refund_amount: 11,
},
other: "data",
}
const swapModel = SwapModel({ findOne: () => existing })
const swapService = new SwapService({ swapModel, fulfillmentService })
it("creates a fulfillment", async () => {
await swapService.createFulfillment(testOrder, IdMap.getId("swap"))
expect(swapModel.updateOne).toHaveBeenCalledWith(
{
_id: IdMap.getId("swap"),
},
{
$set: {
fulfillment_status: "fulfilled",
fulfillments: [{ data: "new" }],
},
}
)
expect(fulfillmentService.createFulfillment).toHaveBeenCalledWith(
{
...existing,
currency_code: testOrder.currency_code,
tax_rate: testOrder.tax_rate,
region_id: testOrder.region_id,
display_id: testOrder.display_id,
is_swap: true,
billing_address: testOrder.billing_address,
items: existing.additional_items,
shipping_methods: existing.shipping_methods,
},
[{ item_id: IdMap.getId("1234"), quantity: 2 }],
{}
)
})
})
describe("failure", () => {})
})
describe("createShipment", () => {
beforeEach(() => {
jest.clearAllMocks()
})
describe("success", () => {
const fulfillmentService = {
createShipment: jest.fn().mockImplementation((o, f) => {
return Promise.resolve({ ...f, data: "new" })
}),
}
const eventBusService = {
emit: jest.fn().mockReturnValue(Promise.resolve()),
}
const existing = {
additional_items: [
{
_id: IdMap.getId("1234-1"),
quantity: 2,
shipped_quantity: 0,
},
],
fulfillments: [
{
_id: IdMap.getId("f1"),
items: [
{
_id: IdMap.getId("1234-1"),
item_id: IdMap.getId("1234-1"),
quantity: 2,
},
],
},
{
_id: IdMap.getId("f2"),
items: [
{
_id: IdMap.getId("1234-2"),
item_id: IdMap.getId("1234-2"),
quantity: 2,
},
],
},
],
shipping_methods: [{ method: "1" }],
return: {
_id: IdMap.getId("return-swap"),
test: "notreceived",
refund_amount: 11,
},
other: "data",
}
const swapModel = SwapModel({
updateOne: () => Promise.resolve(existing),
findOne: () => existing,
})
const swapService = new SwapService({
swapModel,
eventBusService,
fulfillmentService,
})
it("creates a shipment", async () => {
await swapService.createShipment(
IdMap.getId("swap"),
IdMap.getId("f1"),
["1234"],
{}
)
expect(swapModel.updateOne).toHaveBeenCalledWith(
{
_id: IdMap.getId("swap"),
},
{
$set: {
additional_items: [
{
_id: IdMap.getId("1234-1"),
quantity: 2,
shipped: true,
shipped_quantity: 2,
},
],
fulfillment_status: "shipped",
fulfillments: [
{
_id: IdMap.getId("f1"),
items: [
{
_id: IdMap.getId("1234-1"),
item_id: IdMap.getId("1234-1"),
quantity: 2,
},
],
data: "new",
},
{
_id: IdMap.getId("f2"),
items: [
{
_id: IdMap.getId("1234-2"),
item_id: IdMap.getId("1234-2"),
quantity: 2,
},
],
},
],
},
}
)
expect(fulfillmentService.createShipment).toHaveBeenCalledWith(
{
items: existing.additional_items,
shipping_methods: existing.shipping_methods,
},
{
_id: IdMap.getId("f1"),
items: [
{
_id: IdMap.getId("1234-1"),
item_id: IdMap.getId("1234-1"),
quantity: 2,
},
],
},
["1234"],
{}
)
})
})
describe("failure", () => {})
})
describe("requestReturn", () => {
beforeEach(() => {
jest.clearAllMocks()
})
describe("success", () => {
const existing = {
return_items: [{ data: "returnline" }],
return_shipping: { shipping: "return" },
}
const returnService = {
requestReturn: jest
.fn()
.mockReturnValue(Promise.resolve({ return: "data" })),
}
const swapModel = SwapModel({ findOne: () => existing })
const swapService = new SwapService({
swapModel,
returnService,
})
it("calls requestReturn and updates", async () => {
await swapService.requestReturn(testOrder, IdMap.getId("swap"))
expect(returnService.requestReturn).toHaveBeenCalledTimes(1)
expect(returnService.requestReturn).toHaveBeenCalledWith(
testOrder,
existing.return_items,
existing.return_shipping
)
expect(swapModel.updateOne).toHaveBeenCalledWith(
{
_id: IdMap.getId("swap"),
},
{ $set: { return: { return: "data" } } }
)
})
})
})
})
+16 -5
View File
@@ -208,10 +208,19 @@ class CartService extends BaseService {
}
const region = await this.regionService_.retrieve(region_id)
if (region.countries.length === 1) {
// Preselect the country if the region only has 1
data.shipping_address = {
country_code: region.countries[0],
if (!data.shipping_address) {
if (region.countries.length === 1) {
// Preselect the country if the region only has 1
data.shipping_address = {
country_code: region.countries[0],
}
}
} else {
if (!region.countries.includes(data.shipping_address.country_code)) {
throw new MedusaError(
MedusaError.Types.NOT_ALLOWED,
"Shipping country not in region"
)
}
}
@@ -262,7 +271,9 @@ class CartService extends BaseService {
if (Array.isArray(item.content)) {
item.content.forEach(c => products.push(`${c.product._id}`))
} else {
products.push(`${item.content.product._id}`)
if (item.content.product) {
products.push(`${item.content.product._id}`)
}
}
return products
+1 -13
View File
@@ -13,7 +13,7 @@ class CustomerService extends BaseService {
PASSWORD_RESET: "customer.password_reset",
}
constructor({ customerModel, orderService, eventBusService }) {
constructor({ customerModel, eventBusService }) {
super()
/** @private @const {CustomerModel} */
@@ -21,9 +21,6 @@ class CustomerService extends BaseService {
/** @private @const {EventBus} */
this.eventBus_ = eventBusService
/** @private @const {EventBus} */
this.orderService_ = orderService
}
/**
@@ -372,15 +369,6 @@ class CustomerService extends BaseService {
const requiredFields = ["_id", "metadata"]
const decorated = _.pick(customer, fields.concat(requiredFields))
if (expandFields.includes("orders")) {
decorated.orders = await Promise.all(
customer.orders.map(async o => {
const order = await this.orderService_.retrieve(o)
return this.orderService_.decorate(order)
})
)
}
const final = await this.runDecorators_(decorated)
return final
}
+157
View File
@@ -0,0 +1,157 @@
import _ from "lodash"
import { BaseService } from "medusa-interfaces"
import { MedusaError } from "medusa-core-utils"
/**
* Handles Fulfillments
* @implements BaseService
*/
class FulfillmentService extends BaseService {
constructor({
totalsService,
shippingProfileService,
fulfillmentProviderService,
}) {
super()
/** @private @const {TotalsService} */
this.totalsService_ = totalsService
this.shippingProfileService_ = shippingProfileService
this.fulfillmentProviderService_ = fulfillmentProviderService
}
async partitionItems_(shipping_methods, items) {
let updatedMethods = []
// partition order items to their dedicated shipping method
await Promise.all(
shipping_methods.map(async method => {
const { profile_id } = method
const profile = await this.shippingProfileService_.retrieve(profile_id)
// for each method find the items in the order, that are associated
// with the profile on the current shipping method
if (shipping_methods.length === 1) {
method.items = items
} else {
method.items = items.filter(({ content }) => {
if (Array.isArray(content)) {
// we require bundles to have same shipping method, therefore:
return profile.products.includes(content[0].product._id)
} else {
return profile.products.includes(content.product._id)
}
})
}
updatedMethods.push(method)
})
)
return updatedMethods
}
/**
* 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
* retrieved from the order, should return a line item. If the transformer
* returns an undefined value the line item will be filtered from the
* returned array.
* @return {Promise<Array<LineItem>>} the line items generated by the transformer.
*/
async getFulfillmentItems_(order, items, transformer) {
const toReturn = await Promise.all(
items.map(async ({ item_id, quantity }) => {
const item = order.items.find(i => i._id.equals(item_id))
return transformer(item, quantity)
})
)
return toReturn.filter(i => !!i)
}
/**
* Checks that a given quantity of a line item can be fulfilled. Fails if the
* fulfillable quantity is lower than the requested fulfillment quantity.
* Fulfillable quantity is calculated by subtracting the already fulfilled
* quantity from the quantity that was originally purchased.
* @param {LineItem} item - the line item to check has sufficient fulfillable
* quantity.
* @param {number} quantity - the quantity that is requested to be fulfilled.
* @return {LineItem} a line item that has the requested fulfillment quantity
* set.
*/
validateFulfillmentLineItem_(item, quantity) {
if (!item) {
// This will in most cases be called by a webhook so to ensure that
// things go through smoothly in instances where extra items outside
// of Medusa are added we allow unknown items
return null
}
if (quantity > item.quantity - item.fulfilled_quantity) {
throw new MedusaError(
MedusaError.Types.NOT_ALLOWED,
"Cannot fulfill more items than have been purchased"
)
}
return {
...item,
quantity,
}
}
/**
* Creates fulfillments for an order.
* In a situation where the order has more than one shipping method,
* we need to partition the order items, such that they can be sent
* to their respective fulfillment provider.
* @param {string} orderId - id of order to cancel.
* @return {Promise} result of the update operation.
*/
async createFulfillment(order, itemsToFulfill, metadata = {}) {
const lineItems = await this.getFulfillmentItems_(
order,
itemsToFulfill,
this.validateFulfillmentLineItem_
)
const { shipping_methods } = order
// partition order items to their dedicated shipping method
const fulfillments = await this.partitionItems_(shipping_methods, lineItems)
return Promise.all(
fulfillments.map(async method => {
const provider = this.fulfillmentProviderService_.retrieveProvider(
method.provider_id
)
const data = await provider.createOrder(method.data, method.items, {
...order,
})
return {
provider_id: method.provider_id,
items: method.items,
data,
metadata,
}
})
)
}
async createShipment(order, fulfillment, trackingNumbers, metadata) {
return {
...fulfillment,
tracking_numbers: trackingNumbers,
shipped_at: Date.now(),
metadata: {
...fulfillment.metadata,
...metadata,
},
}
}
}
export default FulfillmentService
+209 -300
View File
@@ -13,6 +13,8 @@ class OrderService extends BaseService {
ITEMS_RETURNED: "order.items_returned",
RETURN_ACTION_REQUIRED: "order.return_action_required",
REFUND_CREATED: "order.refund_created",
SWAP_CREATED: "order.swap_created",
SWAP_RECEIVED: "order.swap_received",
PLACED: "order.placed",
UPDATED: "order.updated",
CANCELED: "order.canceled",
@@ -22,22 +24,29 @@ class OrderService extends BaseService {
constructor({
orderModel,
counterService,
customerService,
paymentProviderService,
shippingOptionService,
shippingProfileService,
discountService,
fulfillmentProviderService,
fulfillmentService,
lineItemService,
totalsService,
regionService,
returnService,
swapService,
documentService,
eventBusService,
}) {
super()
/** @private @constantant {OrderModel} */
/** @private @constant {OrderModel} */
this.orderModel_ = orderModel
/** @private @constant {CustomerService} */
this.customerService_ = customerService
/** @private @constantant {PaymentProviderService} */
this.paymentProviderService_ = paymentProviderService
@@ -56,6 +65,12 @@ class OrderService extends BaseService {
/** @private @constant {RegionService} */
this.regionService_ = regionService
/** @private @constant {ReturnService} */
this.returnService_ = returnService
/** @private @constant {FulfillmentService} */
this.fulfillmentService_ = fulfillmentService
/** @private @constant {DiscountService} */
this.discountService_ = discountService
@@ -70,6 +85,9 @@ class OrderService extends BaseService {
/** @private @constant {ShippingOptionService} */
this.shippingOptionService_ = shippingOptionService
/** @private @constant {SwapService} */
this.swapService_ = swapService
}
/**
@@ -126,33 +144,6 @@ class OrderService extends BaseService {
return value
}
async partitionItems_(shipping_methods, items) {
let updatedMethods = []
// partition order items to their dedicated shipping method
await Promise.all(
shipping_methods.map(async method => {
const { profile_id } = method
const profile = await this.shippingProfileService_.retrieve(profile_id)
// for each method find the items in the order, that are associated
// with the profile on the current shipping method
if (shipping_methods.length === 1) {
method.items = items
} else {
method.items = items.filter(({ content }) => {
if (Array.isArray(content)) {
// we require bundles to have same shipping method, therefore:
return profile.products.includes(content[0].product._id)
} else {
return profile.products.includes(content.product._id)
}
})
}
updatedMethods.push(method)
})
)
return updatedMethods
}
/**
* @param {Object} selector - the query object for find
* @return {Promise} the result of the find operation
@@ -419,41 +410,49 @@ class OrderService extends BaseService {
async createShipment(orderId, fulfillmentId, trackingNumbers, metadata = {}) {
const order = await this.retrieve(orderId)
let shipment
const updated = order.fulfillments.map(f => {
if (f._id.equals(fulfillmentId)) {
// For each item in the shipment, we set their status to shipped
f.items.map(item => {
const itemIdx = order.items.findIndex(el => el._id.equals(item._id))
// Update item in order.items and in fullfillment.items to
// ensure consistency
if (item !== -1) {
item.shipped_quantity = item.quantity
order.items[itemIdx].shipped_quantity =
(order.items[itemIdx].shipped_quantity || 0) + item.quantity
}
})
shipment = {
...f,
tracking_numbers: trackingNumbers,
shipped_at: Date.now(),
metadata: {
...f.metadata,
...metadata,
},
}
return shipment
}
return f
})
const shipment = order.fulfillments.find(f => f._id.equals(fulfillmentId))
if (!shipment) {
throw new MedusaError(
MedusaError.Types.NOT_FOUND,
"Could not find fulfillment"
)
}
const updated = await this.fulfillmentService_.createShipment(
order,
shipment,
trackingNumbers,
metadata
)
let fulfillmentStatus = "shipped"
for (const item of order.items) {
if (item.quantity !== item.shipped_quantity) {
fulfillmentStatus = "partially_shipped"
break
const newItems = order.items.map(item => {
const shipped = updated.items.find(fi => item._id.equals(fi._id))
if (shipped) {
// Find the new fulfilled total
const shippedQuantity = (item.shipped_quantity || 0) + shipped.quantity
// If the ordered quantity is not the same as the fulfilled quantity
// the order cannot be marked as fulfilled. We instead set it as
// partially_fulfilled.
if (item.quantity !== shippedQuantity) {
fulfillmentStatus = "partially_shipped"
}
return {
...item,
shipped: item.quantity === shippedQuantity,
shipped_quantity: shippedQuantity,
}
}
}
if (!item.shipped) {
fulfillmentStatus = "partially_shipped"
}
return item
})
// Add the shipment to the order
return this.orderModel_
@@ -462,7 +461,7 @@ class OrderService extends BaseService {
{
$set: {
"fulfillments.$": updated,
items: order.items,
items: newItems,
fulfillment_status: fulfillmentStatus,
},
}
@@ -739,85 +738,62 @@ class OrderService extends BaseService {
async createFulfillment(orderId, itemsToFulfill, metadata = {}) {
const order = await this.retrieve(orderId)
const lineItems = await this.getFulfillmentItems_(
const fulfillments = await this.fulfillmentService_.createFulfillment(
order,
itemsToFulfill,
this.validateFulfillmentLineItem_
metadata
)
const { shipping_methods } = order
const updateFields = {}
// partition order items to their dedicated shipping method
const fulfillments = await this.partitionItems_(shipping_methods, lineItems)
let successfullyFulfilled = []
const results = await Promise.all(
fulfillments.map(async method => {
const provider = this.fulfillmentProviderService_.retrieveProvider(
method.provider_id
)
for (const f of fulfillments) {
successfullyFulfilled = [...successfullyFulfilled, ...f.items]
}
const data = await provider
.createOrder(method.data, method.items, { ...order })
.then(res => {
successfullyFulfilled = [...successfullyFulfilled, ...method.items]
return res
})
method.items = method.items.map(el => {
return {
...el,
fulfilled_quantity: el.quantity,
fulfilled: true,
}
})
return {
provider_id: method.provider_id,
items: method.items,
data,
metadata,
}
})
)
const updateFields = {}
// Reflect the fulfillments in the items
updateFields.items = order.items.map(i => {
const ful = successfullyFulfilled.find(f => i._id.equals(f._id))
if (ful) {
// Find the new fulfilled total
const fulfilledQuantity = i.fulfilled_quantity + ful.quantity
// If the ordered quantity is not the same as the fulfilled quantity
// the order cannot be marked as fulfilled. We instead set it as
// partially_fulfilled.
if (i.quantity !== fulfilledQuantity) {
updateFields.fulfillment_status = "partially_fulfilled"
}
// Update the items
return {
...i,
fulfilled: i.quantity === ful.quantity,
fulfilled_quantity: i.fulfilled_quantity + ful.quantity,
fulfilled: i.quantity === fulfilledQuantity,
fulfilled_quantity: fulfilledQuantity,
}
}
if (!i.fulfilled) {
updateFields.fulfillment_status = "partially_fulfilled"
}
return i
})
updateFields.fulfillment_status = "fulfilled"
for (const el of updateFields.items) {
if (el.quantity !== el.fulfilled_quantity) {
updateFields.fulfillment_status = "partially_fulfilled"
break
}
}
return this.orderModel_
.updateOne(
{
_id: orderId,
},
{
$addToSet: { fulfillments: { $each: results } },
$addToSet: { fulfillments: { $each: fulfillments } },
$set: updateFields,
}
)
.then(result => {
for (const fulfillment of results) {
for (const fulfillment of fulfillments) {
this.eventBus_.emit(OrderService.Events.FULFILLMENT_CREATED, {
order_id: orderId,
fulfillment,
@@ -883,32 +859,6 @@ class OrderService extends BaseService {
}
}
/**
* Checks that an order has the statuses necessary to complete a return.
* fulfillment_status cannot be not_fulfilled or returned.
* payment_status must be captured.
* @param {Order} order - the order to check statuses on
* @throws when statuses are not sufficient for returns.
*/
validateReturnStatuses_(order) {
if (
order.fulfillment_status === "not_fulfilled" ||
order.fulfillment_status === "returned"
) {
throw new MedusaError(
MedusaError.Types.NOT_ALLOWED,
"Can't return an unfulfilled or already returned order"
)
}
if (order.payment_status !== "captured") {
throw new MedusaError(
MedusaError.Types.NOT_ALLOWED,
"Can't return an order with payment unprocessed"
)
}
}
/**
* Generates documents.
* @param {Array<Document>} docs - documents to generate
@@ -941,73 +891,13 @@ class OrderService extends BaseService {
async requestReturn(orderId, items, shippingMethod, refundAmount) {
const order = await this.retrieve(orderId)
// Throws if the order doesn't have the necessary status for return
this.validateReturnStatuses_(order)
let toRefund = refundAmount
if (typeof refundAmount !== "undefined") {
const total = await this.totalsService_.getTotal(order)
const refunded = await this.totalsService_.getRefundedTotal(order)
const refundable = total - refunded
if (refundAmount > refundable) {
throw new MedusaError(
MedusaError.Types.INVALID_DATA,
"Cannot refund more than the original payment"
)
}
} else {
toRefund = await this.totalsService_.getRefundTotal(order, returnLines)
}
const returnLines = await this.getFulfillmentItems_(
const returnRequest = await this.returnService_.requestReturn(
order,
items,
this.validateReturnLineItem_
shippingMethod,
refundAmount
)
let fulfillmentData = {}
let shipping_method = {}
if (typeof shippingMethod !== "undefined") {
shipping_method = await this.shippingOptionService_.retrieve(
shippingMethod.id
)
const provider = await this.fulfillmentProviderService_.retrieveProvider(
shipping_method.provider_id
)
fulfillmentData = await provider.createReturn(
shipping_method.data,
returnLines,
order
)
if (typeof shippingMethod.price !== "undefined") {
shipping_method.price = shippingMethod.price
} else {
shipping_method.price = await this.shippingOptionService_.getPrice(
shipping_method,
{
...order,
items: returnLines,
}
)
}
toRefund = Math.max(0, toRefund - shipping_method.price)
}
const newReturn = {
shipping_method,
refund_amount: toRefund,
items: returnLines.map(i => ({
item_id: i._id,
content: i.content,
quantity: i.quantity,
is_requested: true,
metadata: i.metadata,
})),
shipping_data: fulfillmentData,
}
return this.orderModel_
.updateOne(
{
@@ -1015,14 +905,14 @@ class OrderService extends BaseService {
},
{
$push: {
returns: newReturn,
returns: returnRequest,
},
}
)
.then(result => {
this.eventBus_.emit(OrderService.Events.RETURN_REQUESTED, {
order: result,
return: newReturn,
return: returnRequest,
})
return result
})
@@ -1040,7 +930,13 @@ class OrderService extends BaseService {
* @param {string[]} lineItems - the line items to return
* @return {Promise} the result of the update operation
*/
async return(orderId, returnId, items, refundAmount, allowMismatch = false) {
async receiveReturn(
orderId,
returnId,
items,
refundAmount,
allowMismatch = false
) {
const order = await this.retrieve(orderId)
const returnRequest = order.returns.find(r => r._id.equals(returnId))
if (!returnRequest) {
@@ -1050,99 +946,24 @@ class OrderService extends BaseService {
)
}
if (returnRequest.status === "received") {
throw new MedusaError(
MedusaError.Types.NOT_ALLOWED,
`Return with id ${returnId} has already been received`
)
}
const returnLines = await this.getFulfillmentItems_(
const updatedReturn = await this.returnService_.receiveReturn(
order,
returnRequest,
items,
this.validateReturnLineItem_
refundAmount,
allowMismatch
)
const newLines = returnLines.map(l => {
const existing = returnRequest.items.find(i => l._id.equals(i.item_id))
if (existing) {
return {
...existing,
quantity: l.quantity,
requested_quantity: existing.quantity,
is_requested: l.quantity === existing.quantity,
is_registered: true,
}
} else {
return {
item_id: l._id,
content: l.content,
quantity: l.quantity,
is_requested: false,
is_registered: true,
metadata: l.metadata,
}
}
})
const isMatching = newLines.every(l => l.is_requested)
if (!isMatching && !allowMismatch) {
// Should update status
const newReturns = order.returns.map(r => {
if (r._id.equals(returnId)) {
return {
...r,
status: "requires_action",
items: newLines,
}
} else {
return r
}
})
if (updatedReturn.status === "requires_action") {
return this.orderModel_
.updateOne(
{
_id: orderId,
"returns._id": updatedReturn._id,
},
{
$set: {
returns: newReturns,
},
}
)
.then(result => {
this.eventBus_.emit(OrderService.Events.RETURN_ACTION_REQUIRED, {
order: result,
return: result.returns.find(r => r._id.equals(returnId)),
})
return result
})
}
const toRefund = refundAmount || returnRequest.refund_amount
const total = await this.totalsService_.getTotal(order)
const refunded = await this.totalsService_.getRefundedTotal(order)
if (toRefund > total - refunded) {
const newReturns = order.returns.map(r => {
if (r._id.equals(returnId)) {
return {
...r,
status: "requires_action",
items: newLines,
}
} else {
return r
}
})
return this.orderModel_
.updateOne(
{
_id: orderId,
},
{
$set: {
returns: newReturns,
"returns.$": updatedReturn,
},
}
)
@@ -1157,7 +978,7 @@ class OrderService extends BaseService {
let isFullReturn = true
const newItems = order.items.map(i => {
const isReturn = returnLines.find(r => r._id.equals(i._id))
const isReturn = updatedReturn.items.find(r => i._id.equals(r.item_id))
if (isReturn) {
const returnedQuantity = i.returned_quantity + isReturn.quantity
let returned = i.quantity === returnedQuantity
@@ -1177,18 +998,9 @@ class OrderService extends BaseService {
}
})
const newReturns = order.returns.map(r => {
if (r._id.equals(returnId)) {
return {
...r,
status: "received",
items: newLines,
refund_amount: toRefund,
}
} else {
return r
}
})
const newReturns = order.returns.map(r =>
r._id.equals(returnId) ? updatedReturn : r
)
const update = {
$set: {
@@ -1198,15 +1010,15 @@ class OrderService extends BaseService {
},
}
if (toRefund > 0) {
if (updatedReturn.refund_amount > 0) {
const { provider_id, data } = order.payment_method
const paymentProvider = this.paymentProviderService_.retrieveProvider(
provider_id
)
await paymentProvider.refundPayment(data, toRefund)
await paymentProvider.refundPayment(data, updatedReturn.refund_amount)
update.$push = {
refunds: {
amount: toRefund,
amount: updatedReturn.refund_amount,
},
}
}
@@ -1214,7 +1026,7 @@ class OrderService extends BaseService {
return this.orderModel_.updateOne({ _id: orderId }, update).then(result => {
this.eventBus_.emit(OrderService.Events.ITEMS_RETURNED, {
order: result,
return: result.returns.find(r => r._id.equals(returnId)),
return: updatedReturn,
})
return result
})
@@ -1375,6 +1187,22 @@ class OrderService extends BaseService {
o.created = order._id.getTimestamp()
if (expandFields.includes("swaps")) {
if (order.swaps) {
o.swaps = await Promise.all(
order.swaps.map(sId => {
return this.swapService_.retrieve(sId)
})
)
} else {
o.swaps = []
}
}
if (expandFields.includes("customer")) {
o.customer = await this.customerService_.retrieve(order.customer_id)
}
if (expandFields.includes("region")) {
o.region = await this.regionService_.retrieve(order.region_id)
}
@@ -1422,6 +1250,87 @@ class OrderService extends BaseService {
})
}
/**
* Registers a swap to the order. The swap must belong to the order for it to
* be registered.
* @param {string} id - the id of the order to register the swap to.
* @param {string} swapId - the id of the swap to add to the order.
* @returns {Promise<Order>} the resulting order
*/
async registerSwapCreated(id, swapId) {
const order = await this.retrieve(id)
const swap = await this.swapService_.retrieve(swapId)
if (!order._id.equals(swap.order_id)) {
throw new MedusaError(
MedusaError.Types.NOT_ALLOWED,
"Swap must belong to the given order"
)
}
return this.orderModel_
.updateOne({ _id: order._id }, { $addToSet: { swaps: swapId } })
.then(result => {
this.eventBus_.emit(OrderService.Events.SWAP_CREATED, {
order: result,
swap_id: swapId,
})
return result
})
}
/**
* 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) {
const order = await this.retrieve(id)
const swap = await this.swapService_.retrieve(swapId)
if (!order._id.equals(swap.order_id)) {
throw new MedusaError(
MedusaError.Types.NOT_ALLOWED,
"Swap must belong to the given order"
)
}
if (swap.return.status !== "received") {
throw new MedusaError(
MedusaError.Types.NOT_ALLOWED,
"Swap is not received"
)
}
const newItems = order.items.map(i => {
const isReturn = swap.return_items.find(ri => i._id.equals(ri.item_id))
if (isReturn) {
const returnedQuantity = i.returned_quantity + isReturn.quantity
const returned = returnedQuantity === i.quantity
return {
...i,
returned_quantity: returnedQuantity,
returned,
}
}
return i
})
return this.orderModel_
.updateOne({ _id: order._id }, { $set: { items: newItems } })
.then(result => {
this.eventBus_.emit(OrderService.Events.SWAP_RECEIVED, {
order: result,
swap_id: swapId,
})
return result
})
}
/**
* Dedicated method to delete metadata for an order.
* @param {string} orderId - the order to delete metadata from.
+274
View File
@@ -0,0 +1,274 @@
import _ from "lodash"
import { BaseService } from "medusa-interfaces"
import { MedusaError } from "medusa-core-utils"
/**
* Handles Returns
* @implements BaseService
*/
class ReturnService extends BaseService {
constructor({
totalsService,
shippingOptionService,
fulfillmentProviderService,
}) {
super()
/** @private @const {TotalsService} */
this.totalsService_ = totalsService
this.shippingOptionService_ = shippingOptionService
this.fulfillmentProviderService_ = fulfillmentProviderService
}
/**
* 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
* retrieved from the order, should return a line item. If the transformer
* returns an undefined value the line item will be filtered from the
* returned array.
* @return {Promise<Array<LineItem>>} the line items generated by the transformer.
*/
async getFulfillmentItems_(order, items, transformer) {
const toReturn = await Promise.all(
items.map(async ({ item_id, quantity }) => {
const item = order.items.find(i => i._id.equals(item_id))
return transformer(item, quantity)
})
)
return toReturn.filter(i => !!i)
}
/**
* Checks that an order has the statuses necessary to complete a return.
* fulfillment_status cannot be not_fulfilled or returned.
* payment_status must be captured.
* @param {Order} order - the order to check statuses on
* @throws when statuses are not sufficient for returns.
*/
validateReturnStatuses_(order) {
if (
order.fulfillment_status === "not_fulfilled" ||
order.fulfillment_status === "returned"
) {
throw new MedusaError(
MedusaError.Types.NOT_ALLOWED,
"Can't return an unfulfilled or already returned order"
)
}
if (order.payment_status !== "captured") {
throw new MedusaError(
MedusaError.Types.NOT_ALLOWED,
"Can't return an order with payment unprocessed"
)
}
}
/**
* 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
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(order, items, shippingMethod, refundAmount) {
// Throws if the order doesn't have the necessary status for return
this.validateReturnStatuses_(order)
const returnLines = await this.getFulfillmentItems_(
order,
items,
this.validateReturnLineItem_
)
let toRefund = refundAmount
if (typeof refundAmount !== "undefined") {
const total = await this.totalsService_.getTotal(order)
const refunded = await this.totalsService_.getRefundedTotal(order)
const refundable = total - refunded
if (refundAmount > refundable) {
throw new MedusaError(
MedusaError.Types.INVALID_DATA,
"Cannot refund more than the original payment"
)
}
} else {
toRefund = await this.totalsService_.getRefundTotal(order, returnLines)
}
let fulfillmentData = {}
let shipping_method = {}
if (typeof shippingMethod !== "undefined") {
shipping_method = await this.shippingOptionService_.retrieve(
shippingMethod.id
)
const provider = await this.fulfillmentProviderService_.retrieveProvider(
shipping_method.provider_id
)
fulfillmentData = await provider.createReturn(
shipping_method.data,
returnLines,
order
)
if (typeof shippingMethod.price !== "undefined") {
shipping_method.price = shippingMethod.price
} else {
shipping_method.price = await this.shippingOptionService_.getPrice(
shipping_method,
{
...order,
items: returnLines,
}
)
}
toRefund = Math.max(
0,
toRefund - shipping_method.price * (1 + order.tax_rate)
)
}
return {
shipping_method,
refund_amount: toRefund,
items: returnLines.map(i => ({
item_id: i._id,
content: i.content,
quantity: i.quantity,
is_requested: true,
metadata: i.metadata,
})),
shipping_data: fulfillmentData,
}
}
/**
* 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(
order,
returnRequest,
items,
refundAmount,
allowMismatch = false
) {
if (returnRequest.status === "received") {
throw new MedusaError(
MedusaError.Types.NOT_ALLOWED,
`Return with id ${returnId} has already been received`
)
}
const returnLines = await this.getFulfillmentItems_(
order,
items,
this.validateReturnLineItem_
)
const newLines = returnLines.map(l => {
const existing = returnRequest.items.find(i => l._id.equals(i.item_id))
if (existing) {
return {
...existing,
quantity: l.quantity,
requested_quantity: existing.quantity,
is_requested: l.quantity === existing.quantity,
is_registered: true,
}
} else {
return {
item_id: l._id,
content: l.content,
quantity: l.quantity,
is_requested: false,
is_registered: true,
metadata: l.metadata,
}
}
})
const isMatching = newLines.every(l => l.is_requested)
if (!isMatching && !allowMismatch) {
// Should update status
return {
...returnRequest,
status: "requires_action",
items: newLines,
}
}
const toRefund = refundAmount || returnRequest.refund_amount
const total = await this.totalsService_.getTotal(order)
const refunded = await this.totalsService_.getRefundedTotal(order)
if (toRefund > total - refunded) {
return {
...returnRequest,
status: "requires_action",
items: newLines,
}
}
return {
...returnRequest,
status: "received",
items: newLines,
refund_amount: toRefund,
}
}
}
export default ReturnService
@@ -394,8 +394,11 @@ class ShippingProfileService extends BaseService {
}
})
} else {
if (!acc.includes(next.content.product._id)) {
acc.push(next.content.product._id)
// We may have line items that are not associated with a product
if (next.content.product) {
if (!acc.includes(next.content.product._id)) {
acc.push(next.content.product._id)
}
}
}
+673
View File
@@ -0,0 +1,673 @@
import _ from "lodash"
import { BaseService } from "medusa-interfaces"
import { Validator, MedusaError } from "medusa-core-utils"
/**
* Handles swaps
* @implements BaseService
*/
class SwapService extends BaseService {
static Events = {
SHIPMENT_CREATED: "swap.shipment_created",
PAYMENT_COMPLETED: "swap.payment_completed",
PAYMENT_CAPTURED: "swap.payment_captured",
PAYMENT_CAPTURE_FAILED: "swap.payment_capture_failed",
}
constructor({
swapModel,
eventBusService,
cartService,
totalsService,
returnService,
lineItemService,
paymentProviderService,
shippingOptionService,
fulfillmentService,
}) {
super()
/** @private @const {SwapModel} */
this.swapModel_ = swapModel
/** @private @const {TotalsService} */
this.totalsService_ = totalsService
/** @private @const {LineItemService} */
this.lineItemService_ = lineItemService
/** @private @const {ReturnService} */
this.returnService_ = returnService
/** @private @const {PaymentProviderService} */
this.paymentProviderService_ = paymentProviderService
/** @private @const {CartService} */
this.cartService_ = cartService
/** @private @const {FulfillmentService} */
this.fulfillmentService_ = fulfillmentService
/** @private @const {ShippingOptionService} */
this.shippingOptionService_ = shippingOptionService
/** @private @const {EventBusService} */
this.eventBus_ = eventBusService
}
/**
* Used to validate user ids. Throws an error if the cast fails
* @param {string} rawId - the raw user id to validate.
* @return {string} the validated id
*/
validateId_(rawId) {
const schema = Validator.objectId()
const { value, error } = schema.validate(rawId.toString())
if (error) {
throw new MedusaError(
MedusaError.Types.INVALID_ARGUMENT,
"The swapId could not be casted to an ObjectId"
)
}
return value
}
/**
* Retrieves a swap with the given id.
* @param {string} id - the id of the swap to retrieve
* @return {Promise<Swap>} the swap
*/
async retrieve(id) {
const validatedId = this.validateId_(id)
const swap = await this.swapModel_.findOne({ _id: validatedId })
if (!swap) {
throw new MedusaError(MedusaError.Types.NOT_FOUND, "Swap was not found")
}
return swap
}
/**
* Retrieves a swap based on its associated cart id
* @param {string} cartId - the cart id that the swap's cart has
* @return {Promise<Swap>} the swap
*/
async retrieveByCartId(cartId) {
const swap = await this.swapModel_.findOne({ cart_id: cartId })
if (!swap) {
throw new MedusaError(MedusaError.Types.NOT_FOUND, "Swap was not found")
}
return swap
}
/**
* @typedef OrderLike
* @property {Array<LineItem>} items - the items on the order
*/
/**
* @typedef ReturnItem
* @property {string} item_id - the id of the item in the order to return from.
* @property {number} quantity - the amount of the item to return.
*/
/**
* Goes through a list of return items to ensure that they exist on the
* original order. If the item exists it is verified that the quantity to
* return is not higher than the original quantity ordered.
* @param {OrderLike} order - the order to return from
* @param {Array<ReturnItem>} returnItems - the items to return
* @return {Array<ReturnItems>} the validated returnItems
*/
validateReturnItems_(order, returnItems) {
return returnItems.map(({ item_id, quantity }) => {
const item = order.items.find(i => i._id.equals(item_id))
// The item must exist in the order
if (!item) {
throw new MedusaError(
MedusaError.Types.INVALID_DATA,
"Item does not exist on order"
)
}
// Item's cannot be returned multiple times
if (item.quantity < item.returned_quantity + quantity) {
throw new MedusaError(
MedusaError.Types.INVALID_DATA,
"Cannot return more items than have been ordered"
)
}
return { item_id, quantity }
})
}
/**
* @typedef PreliminaryLineItem
* @property {string} variant_id - the id of the variant to create an item from
* @property {number} quantity - the amount of the variant to add to the line item
*/
/**
* Creates a swap from an order, with given return items, additional items
* and an optional return shipping method.
* @param {Order} order - the order to base the swap off.
* @param {Array<ReturnItem>} returnItems - the items to return in the swap.
* @param {Array<PreliminaryLineItem>} additionalItems - the items to send to
* the customer.
* @param {ReturnShipping?} returnShipping - an optional shipping method for
* returning the returnItems.
* @returns {Promise<Swap>} the newly created swap.
*/
async create(order, returnItems, additionalItems, returnShipping) {
if (
order.fulfillment_status === "not_fulfilled" ||
order.payment_status !== "captured"
) {
throw new MedusaError(
MedusaError.Types.NOT_ALLOWED,
"Order cannot be swapped"
)
}
const newItems = await Promise.all(
additionalItems.map(({ variant_id, quantity }) => {
return this.lineItemService_.generate(
variant_id,
order.region_id,
quantity
)
})
)
const validatedReturnItems = this.validateReturnItems_(order, returnItems)
return this.swapModel_.create({
order_id: order._id,
region_id: order.region_id,
currency_code: order.currency_code,
return_items: validatedReturnItems,
return_shipping: returnShipping,
additional_items: newItems,
})
}
async capturePayment(swapId) {
const swap = await this.retrieve(swapId)
if (swap.payment_status !== "awaiting") {
throw new MedusaError(
MedusaError.Types.INVALID_ARGUMENT,
"Payment already captured"
)
}
const updateFields = { payment_status: "captured" }
const { provider_id, data } = swap.payment_method
const paymentProvider = await this.paymentProviderService_.retrieveProvider(
provider_id
)
try {
await paymentProvider.capturePayment(data)
} catch (error) {
return this.swapModel_
.updateOne(
{
_id: swapId,
},
{
$set: { payment_status: "requires_action" },
}
)
.then(result => {
this.eventBus_.emit(SwapService.Events.PAYMENT_CAPTURE_FAILED, result)
return result
})
}
return this.swapModel_
.updateOne(
{
_id: swapId,
},
{
$set: updateFields,
}
)
.then(result => {
this.eventBus_.emit(SwapService.Events.PAYMENT_CAPTURED, result)
return result
})
}
/**
* Creates a cart from the given swap and order. The cart can be used to pay
* for differences associated with the swap. The swap represented by the
* swapId must belong to the order. Fails if there is already a cart on the
* swap.
* @param {Order} order - the order to create the cart from
* @param {string} swapId - the id of the swap to create the cart from
* @returns {Promise<Swap>} the swap with its cart_id prop set to the id of
* the new cart.
*/
async createCart(order, swapId) {
const swap = await this.retrieve(swapId)
if (!order._id.equals(swap.order_id)) {
throw new MedusaError(
MedusaError.Types.INVALID_DATA,
"The swap does not belong to the order"
)
}
if (swap.cart_id) {
throw new MedusaError(
MedusaError.Types.NOT_ALLOWED,
"A cart has already been created for the swap"
)
}
// Add return lines to the cart to ensure that the total calculation is
// correct.
const returnLines = swap.return_items.map(r => {
const lineItem = order.items.find(i => i._id.equals(r.item_id))
return {
...lineItem,
content: {
...lineItem.content,
unit_price: -1 * lineItem.content.unit_price,
},
quantity: r.quantity,
metadata: {
...lineItem.metadata,
is_return_line: true,
},
}
})
// If the swap has a return shipping method the price has to be added to the
// cart.
if (swap.return_shipping) {
returnLines.push({
title: "Return shipping",
quantity: 1,
has_shipping: true,
content: {
unit_price: swap.return_shipping.price,
quantity: 1,
},
metadata: {
is_return_line: true,
},
})
}
const cart = await this.cartService_.create({
email: order.email,
billing_address: order.billing_address,
shipping_address: order.shipping_address,
items: [...returnLines, ...swap.additional_items],
region_id: order.region_id,
customer_id: order.customer_id,
is_swap: true,
metadata: {
swap_id: swap._id,
parent_order_id: order._id,
},
})
return this.swapModel_.updateOne(
{ _id: swapId },
{ $set: { cart_id: cart._id } }
)
}
/**
*
*/
async registerCartCompletion(swapId, cartId) {
const swap = await this.retrieve(swapId)
const cart = await this.cartService_.retrieve(cartId)
// If we already registered the cart completion we just return
if (swap.is_paid) {
return swap
}
if (!cart._id.equals(swap.cart_id)) {
throw new MedusaError(
MedusaError.Types.NOT_ALLOWED,
"Cart does not belong to swap"
)
}
let paymentSession = {}
let paymentData = {}
const { payment_method, payment_sessions } = cart
if (!payment_method) {
throw new MedusaError(
MedusaError.Types.INVALID_ARGUMENT,
"Cart does not contain a payment method"
)
}
if (!payment_sessions || !payment_sessions.length) {
throw new MedusaError(
MedusaError.Types.INVALID_ARGUMENT,
"cart must have payment sessions"
)
}
paymentSession = payment_sessions.find(
ps => ps.provider_id === payment_method.provider_id
)
// Throw if payment method does not exist
if (!paymentSession) {
throw new MedusaError(
MedusaError.Types.INVALID_ARGUMENT,
"Cart does not have an authorized payment session"
)
}
const paymentProvider = this.paymentProviderService_.retrieveProvider(
paymentSession.provider_id
)
const paymentStatus = await paymentProvider.getStatus(paymentSession.data)
// If payment status is not authorized, we throw
if (paymentStatus !== "authorized" && paymentStatus !== "succeeded") {
throw new MedusaError(
MedusaError.Types.INVALID_ARGUMENT,
"Payment method is not authorized"
)
}
paymentData = await paymentProvider.retrievePayment(paymentSession.data)
let payment = {}
if (paymentSession.provider_id) {
payment = {
provider_id: paymentSession.provider_id,
data: paymentData,
}
}
const total = await this.totalsService_.getTotal(cart)
return this.swapModel_
.updateOne(
{ _id: swap._id },
{
shipping_address: cart.shipping_address,
shipping_methods: cart.shipping_methods,
is_paid: true,
amount_paid: total,
payment_method: payment,
}
)
.then(result => {
this.eventBus_.emit(SwapService.Events.PAYMENT_COMPLETED, {
swap: result,
})
return result
})
}
/**
* Requests a return based off an order and a swap.
* @param {Order} order - the order to create the return from.
* @param {string} swapId - the id to create the return from
* @returns {Promise<Swap>} the swap
*/
async requestReturn(order, swapId) {
const swap = await this.retrieve(swapId)
const newReturn = await this.returnService_.requestReturn(
order,
swap.return_items,
swap.return_shipping
)
return this.swapModel_.updateOne(
{ _id: swapId },
{ $set: { return: newReturn } }
)
}
/**
* Registers the return associated with a swap as received. If the return
* is received with mismatching return items the swap's status will be updated
* to requires_action.
* @param {Order} order - the order to receive the return based off
* @param {string} swapId - the id of the swap to receive.
* @param {Array<ReturnItem>} - the items that have been returned
* @returns {Promise<Swap>} the resulting swap, with an updated return and
* status.
*/
async receiveReturn(order, swapId, returnItems) {
const swap = await this.retrieve(swapId)
const returnRequest = swap.return
if (!returnRequest || !returnRequest._id) {
throw new MedusaError(
MedusaError.Types.INVALID_DATA,
"Swap has no return request"
)
}
if (!order._id.equals(swap.order_id)) {
throw new MedusaError(
MedusaError.Types.INVALID_DATA,
"The swap does not belong to the order"
)
}
const updatedReturn = await this.returnService_.receiveReturn(
order,
returnRequest,
returnItems,
returnRequest.refund_amount,
false
)
let status = "received"
if (updatedReturn.status === "requires_action") {
status = "requires_action"
}
return this.swapModel_.updateOne(
{
_id: swapId,
},
{
$set: {
status,
return: updatedReturn,
},
}
)
}
/**
* Fulfills the addtional items associated with the swap. Will call the
* fulfillment providers associated with the shipping methods.
* @param {string} swapId - the id of the swap to fulfill,
* @param {object} metadata - optional metadata to attach to the fulfillment.
* @returns {Promise<Swap>} the updated swap with new status and fulfillments.
*/
async createFulfillment(order, swapId, metadata = {}) {
const swap = await this.retrieve(swapId)
const fulfillments = await this.fulfillmentService_.createFulfillment(
{
...swap,
currency_code: order.currency_code,
tax_rate: order.tax_rate,
region_id: order.region_id,
display_id: order.display_id,
billing_address: order.billing_address,
items: swap.additional_items,
shipping_methods: swap.shipping_methods,
is_swap: true,
},
swap.additional_items.map(i => ({
item_id: i._id,
quantity: i.quantity,
})),
metadata
)
return this.swapModel_.updateOne(
{
_id: swapId,
},
{
$set: {
fulfillment_status: "fulfilled",
fulfillments,
},
}
)
}
/**
* Marks a fulfillment as shipped and attaches tracking numbers.
* @param {string} swapId - the id of the swap that has been shipped.
* @param {string} fulfillmentId - the id of the specific fulfillment that
* has been shipped
* @param {Array<string>} trackingNumbers - the tracking numbers associated
* with the shipment
* @param {object} metadata - optional metadata to attach to the shipment.
* @returns {Promise<Swap>} the updated swap with new fulfillments and status.
*/
async createShipment(swapId, fulfillmentId, trackingNumbers, metadata = {}) {
const swap = await this.retrieve(swapId)
// Update the fulfillment to register
const updatedFulfillments = await Promise.all(
swap.fulfillments.map(f => {
if (f._id.equals(fulfillmentId)) {
return this.fulfillmentService_.createShipment(
{
items: swap.additional_items,
shipping_methods: swap.shipping_methods,
},
f,
trackingNumbers,
metadata
)
}
return f
})
)
// Go through all the additional items in the swap
const updatedItems = swap.additional_items.map(i => {
let shipmentItem
for (const fulfillment of updatedFulfillments) {
const item = fulfillment.items.find(fi => i._id.equals(fi._id))
if (!!item) {
shipmentItem = item
break
}
}
if (shipmentItem) {
const shippedQuantity = i.shipped_quantity + shipmentItem.quantity
return {
...i,
shipped: i.quantity === shippedQuantity,
shipped_quantity: shippedQuantity,
}
}
return i
})
const fulfillment_status = updatedItems.every(i => i.shipped)
? "shipped"
: "partially_shipped"
return this.swapModel_
.updateOne(
{ _id: swapId },
{
$set: {
fulfillment_status,
additional_items: updatedItems,
fulfillments: updatedFulfillments,
},
}
)
.then(result => {
this.eventBus_.emit(SwapService.Events.SHIPMENT_CREATED, {
swap_id: swapId,
shipment: result.fulfillments.find(f => f._id.equals(fulfillmentId)),
})
return result
})
}
/**
* Dedicated method to set metadata for a swap.
* To ensure that plugins does not overwrite each
* others metadata fields, setMetadata is provided.
* @param {string} swapId - the swap to decorate.
* @param {string} key - key for metadata field
* @param {string} value - value for metadata field.
* @return {Promise<Swap>} resolves to the updated result.
*/
async setMetadata(swapId, key, value) {
const validatedId = this.validateId_(swapId)
if (typeof key !== "string") {
throw new MedusaError(
MedusaError.Types.INVALID_ARGUMENT,
"Key type is invalid. Metadata keys must be strings"
)
}
const keyPath = `metadata.${key}`
return this.swapModel_
.updateOne({ _id: validatedId }, { $set: { [keyPath]: value } })
.catch(err => {
throw new MedusaError(MedusaError.Types.DB_ERROR, err.message)
})
}
/**
* Dedicated method to delete metadata for a swap.
* @param {string} swapId - the order to delete metadata from.
* @param {string} key - key for metadata field
* @return {Promise} resolves to the updated result.
*/
async deleteMetadata(swapId, key) {
const validatedId = this.validateId_(swapId)
if (typeof key !== "string") {
throw new MedusaError(
MedusaError.Types.INVALID_ARGUMENT,
"Key type is invalid. Metadata keys must be strings"
)
}
const keyPath = `metadata.${key}`
return this.swapModel_
.updateOne({ _id: validatedId }, { $unset: { [keyPath]: "" } })
.catch(err => {
throw new MedusaError(MedusaError.Types.DB_ERROR, err.message)
})
}
}
export default SwapService