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:
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",
|
||||
|
||||
@@ -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: {} },
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 },
|
||||
|
||||
@@ -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: {} },
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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", () => {
|
||||
|
||||
@@ -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" } } }
|
||||
)
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
@@ -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.
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
Reference in new issue
Block a user