Adds OrderService and /orders endpoints

Adds OrderService

Adds endpoints for orders - both store and admin
This commit is contained in:
Oliver Windall Juhl
2020-05-11 13:54:09 +02:00
committed by GitHub
parent 7ca03579db
commit 8255a839f6
39 changed files with 2648 additions and 47 deletions

View File

@@ -8,6 +8,7 @@ import regionRoutes from "./regions"
import shippingOptionRoutes from "./shipping-options"
import shippingProfileRoutes from "./shipping-profiles"
import discountRoutes from "./discounts"
import orderRoutes from "./orders"
const route = Router()
@@ -34,6 +35,7 @@ export default (app, container) => {
shippingOptionRoutes(route)
shippingProfileRoutes(route)
discountRoutes(route)
orderRoutes(route)
// productVariantRoutes(route)
return app

View File

@@ -0,0 +1,40 @@
import { IdMap } from "medusa-test-utils"
import { request } from "../../../../../helpers/test-request"
import { OrderServiceMock } from "../../../../../services/__mocks__/order"
describe("POST /admin/orders/:id/archive", () => {
describe("successfully archives an order", () => {
let subject
beforeAll(async () => {
subject = await request(
"POST",
`/admin/orders/${IdMap.getId("processed-order")}/archive`,
{
adminSession: {
jwt: {
userId: IdMap.getId("admin_user"),
},
},
}
)
})
afterAll(() => {
jest.clearAllMocks()
})
it("calls OrderService archive", () => {
expect(OrderServiceMock.archive).toHaveBeenCalledTimes(1)
expect(OrderServiceMock.archive).toHaveBeenCalledWith(
IdMap.getId("processed-order")
)
})
it("returns order with status = archived", () => {
expect(subject.status).toEqual(200)
expect(subject.body._id).toEqual(IdMap.getId("processed-order"))
expect(subject.body.status).toEqual("archived")
})
})
})

View File

@@ -0,0 +1,40 @@
import { IdMap } from "medusa-test-utils"
import { request } from "../../../../../helpers/test-request"
import { OrderServiceMock } from "../../../../../services/__mocks__/order"
describe("POST /admin/orders/:id/cancel", () => {
describe("successfully cancels an order", () => {
let subject
beforeAll(async () => {
subject = await request(
"POST",
`/admin/orders/${IdMap.getId("test-order")}/cancel`,
{
adminSession: {
jwt: {
userId: IdMap.getId("admin_user"),
},
},
}
)
})
afterAll(() => {
jest.clearAllMocks()
})
it("calls OrderService cancel", () => {
expect(OrderServiceMock.cancel).toHaveBeenCalledTimes(1)
expect(OrderServiceMock.cancel).toHaveBeenCalledWith(
IdMap.getId("test-order")
)
})
it("returns order with status = cancelled", () => {
expect(subject.status).toEqual(200)
expect(subject.body._id).toEqual(IdMap.getId("test-order"))
expect(subject.body.status).toEqual("cancelled")
})
})
})

View File

@@ -0,0 +1,40 @@
import { IdMap } from "medusa-test-utils"
import { request } from "../../../../../helpers/test-request"
import { OrderServiceMock } from "../../../../../services/__mocks__/order"
describe("POST /admin/orders/:id/capture", () => {
describe("successfully captures payment for an order", () => {
let subject
beforeAll(async () => {
subject = await request(
"POST",
`/admin/orders/${IdMap.getId("test-order")}/capture`,
{
adminSession: {
jwt: {
userId: IdMap.getId("admin_user"),
},
},
}
)
})
afterAll(() => {
jest.clearAllMocks()
})
it("calls OrderService capturePayment", () => {
expect(OrderServiceMock.capturePayment).toHaveBeenCalledTimes(1)
expect(OrderServiceMock.capturePayment).toHaveBeenCalledWith(
IdMap.getId("test-order")
)
})
it("returns order with payment_status = captured", () => {
expect(subject.status).toEqual(200)
expect(subject.body._id).toEqual(IdMap.getId("test-order"))
expect(subject.body.payment_status).toEqual("captured")
})
})
})

View File

@@ -0,0 +1,40 @@
import { IdMap } from "medusa-test-utils"
import { request } from "../../../../../helpers/test-request"
import { OrderServiceMock } from "../../../../../services/__mocks__/order"
describe("POST /admin/orders/:id/fulfillment", () => {
describe("successfully fulfills an order", () => {
let subject
beforeAll(async () => {
subject = await request(
"POST",
`/admin/orders/${IdMap.getId("test-order")}/fulfillment`,
{
adminSession: {
jwt: {
userId: IdMap.getId("admin_user"),
},
},
}
)
})
afterAll(() => {
jest.clearAllMocks()
})
it("calls OrderService createFulfillment", () => {
expect(OrderServiceMock.createFulfillment).toHaveBeenCalledTimes(1)
expect(OrderServiceMock.createFulfillment).toHaveBeenCalledWith(
IdMap.getId("test-order")
)
})
it("returns order with fulfillment_status = fulfilled", () => {
expect(subject.status).toEqual(200)
expect(subject.body._id).toEqual(IdMap.getId("test-order"))
expect(subject.body.fulfillment_status).toEqual("fulfilled")
})
})
})

View File

@@ -0,0 +1,140 @@
import { IdMap } from "medusa-test-utils"
import { request } from "../../../../../helpers/test-request"
import {
orders,
OrderServiceMock,
} from "../../../../../services/__mocks__/order"
describe("POST /admin/orders", () => {
describe("successful creation", () => {
let subject
beforeAll(async () => {
subject = await request("POST", "/admin/orders", {
payload: {
email: "virgil@vandijk.dk",
billing_address: {
first_name: "Virgil",
last_name: "Van Dijk",
address_1: "24 Dunks Drive",
city: "Los Angeles",
country_code: "US",
province: "CA",
postal_code: "93011",
},
shipping_address: {
first_name: "Virgil",
last_name: "Van Dijk",
address_1: "24 Dunks Drive",
city: "Los Angeles",
country_code: "US",
province: "CA",
postal_code: "93011",
},
items: [
{
_id: IdMap.getId("existingLine"),
title: "merge line",
description: "This is a new line",
thumbnail: "test-img-yeah.com/thumb",
content: {
unit_price: 123,
variant: {
_id: IdMap.getId("can-cover"),
},
product: {
_id: IdMap.getId("validId"),
},
quantity: 1,
},
quantity: 10,
},
],
region: IdMap.getId("testRegion"),
customer_id: IdMap.getId("testCustomer"),
payment_method: {
provider_id: "default_provider",
data: {},
},
shipping_method: [
{
provider_id: "default_provider",
profile_id: IdMap.getId("validId"),
price: 123,
data: {},
items: [],
},
],
},
adminSession: {
jwt: {
userId: IdMap.getId("admin_user"),
},
},
})
})
it("returns 200", () => {
expect(subject.status).toEqual(200)
})
it("calls OrderService create", () => {
expect(OrderServiceMock.create).toHaveBeenCalledTimes(1)
expect(OrderServiceMock.create).toHaveBeenCalledWith({
email: "virgil@vandijk.dk",
billing_address: {
first_name: "Virgil",
last_name: "Van Dijk",
address_1: "24 Dunks Drive",
city: "Los Angeles",
country_code: "US",
province: "CA",
postal_code: "93011",
},
shipping_address: {
first_name: "Virgil",
last_name: "Van Dijk",
address_1: "24 Dunks Drive",
city: "Los Angeles",
country_code: "US",
province: "CA",
postal_code: "93011",
},
items: [
{
_id: IdMap.getId("existingLine"),
title: "merge line",
description: "This is a new line",
thumbnail: "test-img-yeah.com/thumb",
content: {
unit_price: 123,
variant: {
_id: IdMap.getId("can-cover"),
},
product: {
_id: IdMap.getId("validId"),
},
quantity: 1,
},
quantity: 10,
},
],
region: IdMap.getId("testRegion"),
customer_id: IdMap.getId("testCustomer"),
payment_method: {
provider_id: "default_provider",
data: {},
},
shipping_method: [
{
provider_id: "default_provider",
profile_id: IdMap.getId("validId"),
price: 123,
data: {},
items: [],
},
],
})
})
})
})

View File

@@ -0,0 +1,39 @@
import { IdMap } from "medusa-test-utils"
import { request } from "../../../../../helpers/test-request"
import { OrderServiceMock } from "../../../../../services/__mocks__/order"
describe("GET /admin/orders", () => {
describe("successfully gets an order", () => {
let subject
beforeAll(async () => {
subject = await request(
"GET",
`/admin/orders/${IdMap.getId("test-order")}`,
{
adminSession: {
jwt: {
userId: IdMap.getId("admin_user"),
},
},
}
)
})
afterAll(() => {
jest.clearAllMocks()
})
it("calls orderService retrieve", () => {
expect(OrderServiceMock.retrieve).toHaveBeenCalledTimes(1)
expect(OrderServiceMock.retrieve).toHaveBeenCalledWith(
IdMap.getId("test-order")
)
})
it("returns order", () => {
expect(subject.status).toEqual(200)
expect(subject.body._id).toEqual(IdMap.getId("test-order"))
})
})
})

View File

@@ -0,0 +1,74 @@
import { IdMap } from "medusa-test-utils"
import { request } from "../../../../../helpers/test-request"
import { OrderServiceMock } from "../../../../../services/__mocks__/order"
describe("POST /admin/orders/:id/return", () => {
describe("successfully returns full order", () => {
let subject
beforeAll(async () => {
subject = await request(
"POST",
`/admin/orders/${IdMap.getId("test-order")}/return`,
{
payload: {
items: [
{
_id: IdMap.getId("existingLine"),
title: "merge line",
description: "This is a new line",
thumbnail: "test-img-yeah.com/thumb",
content: {
unit_price: 123,
variant: {
_id: IdMap.getId("can-cover"),
},
product: {
_id: IdMap.getId("validId"),
},
quantity: 1,
},
quantity: 10,
},
],
},
adminSession: {
jwt: {
userId: IdMap.getId("admin_user"),
},
},
}
)
})
it("returns 200", () => {
expect(subject.status).toEqual(200)
})
it("calls OrderService return", () => {
expect(OrderServiceMock.return).toHaveBeenCalledTimes(1)
expect(OrderServiceMock.return).toHaveBeenCalledWith(
IdMap.getId("test-order"),
[
{
_id: IdMap.getId("existingLine"),
title: "merge line",
description: "This is a new line",
thumbnail: "test-img-yeah.com/thumb",
content: {
unit_price: 123,
variant: {
_id: IdMap.getId("can-cover"),
},
product: {
_id: IdMap.getId("validId"),
},
quantity: 1,
},
quantity: 10,
},
]
)
})
})
})

View File

@@ -0,0 +1,63 @@
import { IdMap } from "medusa-test-utils"
import { request } from "../../../../../helpers/test-request"
import { OrderServiceMock } from "../../../../../services/__mocks__/order"
describe("POST /admin/orders/:id", () => {
describe("successfully updates an order", () => {
let subject
beforeAll(async () => {
subject = await request(
"POST",
`/admin/orders/${IdMap.getId("test-order")}`,
{
payload: {
email: "oliver@test.dk",
},
adminSession: {
jwt: {
userId: IdMap.getId("admin_user"),
},
},
}
)
})
it("returns 200", () => {
expect(subject.status).toEqual(200)
})
it("calls OrderService update", () => {
expect(OrderServiceMock.update).toHaveBeenCalledTimes(1)
expect(OrderServiceMock.update).toHaveBeenCalledWith(
IdMap.getId("test-order"),
{
email: "oliver@test.dk",
}
)
})
})
describe("handles failed update operation", () => {
it("throws if metadata is to be updated", async () => {
try {
await request("POST", `/admin/orders/${IdMap.getId("test-order")}`, {
payload: {
_id: IdMap.getId("test-order"),
metadata: "Test Description",
},
adminSession: {
jwt: {
userId: IdMap.getId("admin_user"),
},
},
})
} catch (error) {
expect(error.status).toEqual(400)
expect(error.message).toEqual(
"Use setMetadata to update metadata fields"
)
}
})
})
})

View File

@@ -0,0 +1,11 @@
export default async (req, res) => {
const { id } = req.params
try {
const orderService = req.scope.resolve("orderService")
const order = await orderService.archive(id)
res.json(order)
} catch (error) {
throw error
}
}

View File

@@ -0,0 +1,11 @@
export default async (req, res) => {
const { id } = req.params
try {
const orderService = req.scope.resolve("orderService")
const order = await orderService.cancel(id)
res.json(order)
} catch (error) {
throw error
}
}

View File

@@ -0,0 +1,11 @@
export default async (req, res) => {
const { id } = req.params
try {
const orderService = req.scope.resolve("orderService")
const order = await orderService.capturePayment(id)
res.json(order)
} catch (error) {
throw error
}
}

View File

@@ -0,0 +1,11 @@
export default async (req, res) => {
const { id } = req.params
try {
const orderService = req.scope.resolve("orderService")
const order = await orderService.createFulfillment(id)
res.json(order)
} catch (error) {
throw error
}
}

View File

@@ -0,0 +1,46 @@
import { MedusaError, Validator } from "medusa-core-utils"
export default async (req, res) => {
const schema = Validator.object().keys({
status: Validator.string().optional(),
email: Validator.string()
.email()
.required(),
billing_address: Validator.address().required(),
shipping_address: Validator.address().required(),
items: Validator.array().required(),
region: Validator.string().required(),
discounts: Validator.array().optional(),
customer_id: Validator.string().required(),
payment_method: Validator.object()
.keys({
provider_id: Validator.string().required(),
data: Validator.object().optional(),
})
.required(),
shipping_method: Validator.array()
.items({
provider_id: Validator.string().required(),
profile_id: Validator.string().required(),
price: Validator.number().required(),
data: Validator.object().optional(),
items: Validator.array().optional(),
})
.required(),
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 order = await orderService.create(value)
res.status(200).json(order)
} catch (err) {
throw err
}
}

View File

@@ -0,0 +1,12 @@
export default async (req, res) => {
const { id } = req.params
try {
const orderService = req.scope.resolve("orderService")
const order = await orderService.retrieve(id)
res.json(order)
} catch (error) {
throw error
}
}

View File

@@ -0,0 +1,30 @@
import { Router } from "express"
import middlewares from "../../../middlewares"
const route = Router()
export default app => {
app.use("/orders", route)
route.get("/:id", middlewares.wrap(require("./get-order").default))
route.post("/", middlewares.wrap(require("./create-order").default))
route.post("/:id", middlewares.wrap(require("./update-order").default))
route.post(
"/:id/capture",
middlewares.wrap(require("./capture-payment").default)
)
route.post(
"/:id/fulfillment",
middlewares.wrap(require("./create-fulfillment").default)
)
route.post("/:id/return", middlewares.wrap(require("./return-order").default))
route.post("/:id/cancel", middlewares.wrap(require("./cancel-order").default))
route.post(
"/:id/archive",
middlewares.wrap(require("./archive-order").default)
)
return app
}

View File

@@ -0,0 +1,23 @@
import { MedusaError, Validator } from "medusa-core-utils"
export default async (req, res) => {
const { id } = req.params
const schema = Validator.object().keys({
items: Validator.array().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 order = await orderService.return(id, value.items)
res.status(200).json(order)
} catch (err) {
throw err
}
}

View File

@@ -0,0 +1,40 @@
import { MedusaError, Validator } from "medusa-core-utils"
export default async (req, res) => {
const { id } = req.params
const schema = Validator.object().keys({
email: Validator.string().email(),
billing_address: Validator.address(),
shipping_address: Validator.address(),
items: Validator.array(),
region: Validator.string(),
discounts: Validator.array(),
customer_id: Validator.string(),
payment_method: Validator.object().keys({
provider_id: Validator.string(),
data: Validator.object(),
}),
shipping_method: Validator.array().items({
provider_id: Validator.string(),
profile_id: Validator.string(),
price: Validator.number(),
data: Validator.object(),
items: Validator.array(),
}),
})
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 order = await orderService.update(id, value)
res.status(200).json(order)
} catch (err) {
throw err
}
}

View File

@@ -2,6 +2,7 @@ import { Router } from "express"
import productRoutes from "./products"
import cartRoutes from "./carts"
import orderRoutes from "./orders"
import customerRoutes from "./customers"
import shippingOptionRoutes from "./shipping-options"
@@ -12,6 +13,7 @@ export default app => {
customerRoutes(route)
productRoutes(route)
orderRoutes(route)
cartRoutes(route)
shippingOptionRoutes(route)

View File

@@ -0,0 +1,32 @@
import { IdMap } from "medusa-test-utils"
import { request } from "../../../../../helpers/test-request"
import { OrderServiceMock } from "../../../../../services/__mocks__/order"
import { carts } from "../../../../../services/__mocks__/cart"
describe("POST /store/orders", () => {
describe("successful creation", () => {
let subject
beforeAll(async () => {
subject = await request("POST", "/store/orders", {
payload: {
cartId: IdMap.getId("fr-cart"),
},
adminSession: {
jwt: {
userId: IdMap.getId("admin_user"),
},
},
})
})
it("returns 200", () => {
expect(subject.status).toEqual(200)
})
it("calls service create", () => {
expect(OrderServiceMock.create).toHaveBeenCalledTimes(1)
expect(OrderServiceMock.create).toHaveBeenCalledWith(carts.frCart)
})
})
})

View File

@@ -0,0 +1,31 @@
import { IdMap } from "medusa-test-utils"
import { request } from "../../../../../helpers/test-request"
import { OrderServiceMock } from "../../../../../services/__mocks__/order"
describe("GET /store/orders", () => {
describe("successfully gets an order", () => {
let subject
beforeAll(async () => {
subject = await request(
"GET",
`/store/orders/${IdMap.getId("test-order")}`
)
})
afterAll(() => {
jest.clearAllMocks()
})
it("calls orderService retrieve", () => {
expect(OrderServiceMock.retrieve).toHaveBeenCalledTimes(1)
expect(OrderServiceMock.retrieve).toHaveBeenCalledWith(
IdMap.getId("test-order")
)
})
it("returns order", () => {
expect(subject.body._id).toEqual(IdMap.getId("test-order"))
})
})
})

View File

@@ -0,0 +1,39 @@
import { MedusaError, Validator } from "medusa-core-utils"
export default async (req, res) => {
const schema = Validator.object().keys({
cartId: Validator.string().required(),
})
const { value, error } = schema.validate(req.body)
if (error) {
throw new MedusaError(MedusaError.Types.INVALID_DATA, error.details)
}
try {
const cartService = req.scope.resolve("cartService")
const orderService = req.scope.resolve("orderService")
const cart = await cartService.retrieve(value.cartId)
let order = await orderService.create(cart)
order = await orderService.decorate(order, [
"status",
"fulfillment_status",
"payment_status",
"email",
"billing_address",
"shipping_address",
"items",
"region",
"discounts",
"customer_id",
"payment_method",
"shipping_methods",
"metadata",
])
res.status(200).json(order)
} catch (err) {
throw err
}
}

View File

@@ -0,0 +1,27 @@
export default async (req, res) => {
const { id } = req.params
try {
const orderService = req.scope.resolve("orderService")
let order = await orderService.retrieve(id)
order = await orderService.decorate(order, [
"status",
"fulfillment_status",
"payment_status",
"email",
"billing_address",
"shipping_address",
"items",
"region",
"discounts",
"customer_id",
"payment_method",
"shipping_methods",
"metadata",
])
res.json(order)
} catch (error) {
throw error
}
}

View File

@@ -0,0 +1,14 @@
import { Router } from "express"
import middlewares from "../../../middlewares"
const route = Router()
export default app => {
app.use("/orders", route)
route.get("/:id", middlewares.wrap(require("./get-order").default))
route.post("/", middlewares.wrap(require("./create-order").default))
return app
}

View File

@@ -0,0 +1,242 @@
import { IdMap } from "medusa-test-utils"
export const orders = {
testOrder: {
_id: IdMap.getId("test-order"),
email: "oliver@test.dk",
billing_address: {
first_name: "Oli",
last_name: "Medusa",
address_1: "testaddress",
city: "LA",
country_code: "US",
postal_code: "90002",
},
shipping_address: {
first_name: "Oli",
last_name: "Medusa",
address_1: "testaddress",
city: "LA",
country_code: "US",
postal_code: "90002",
},
items: [
{
_id: IdMap.getId("existingLine"),
title: "merge line",
description: "This is a new line",
thumbnail: "test-img-yeah.com/thumb",
content: {
unit_price: 123,
variant: {
_id: IdMap.getId("can-cover"),
},
product: {
_id: IdMap.getId("validId"),
},
quantity: 1,
},
quantity: 10,
},
],
region: IdMap.getId("region-france"),
customer_id: IdMap.getId("test-customer"),
payment_method: {
provider_id: "default_provider",
},
shipping_methods: [
{
_id: IdMap.getId("expensiveShipping"),
name: "Expensive Shipping",
price: 100,
provider_id: "default_provider",
profile_id: IdMap.getId("default"),
},
{
_id: IdMap.getId("freeShipping"),
name: "Free Shipping",
price: 10,
provider_id: "default_provider",
profile_id: IdMap.getId("profile1"),
},
],
fulfillment_status: "not_fulfilled",
payment_status: "awaiting",
status: "pending",
},
processedOrder: {
_id: IdMap.getId("processed-order"),
email: "oliver@test.dk",
billing_address: {
first_name: "Oli",
last_name: "Medusa",
address_1: "testaddress",
city: "LA",
country_code: "US",
postal_code: "90002",
},
shipping_address: {
first_name: "Oli",
last_name: "Medusa",
address_1: "testaddress",
city: "LA",
country_code: "US",
postal_code: "90002",
},
items: [
{
_id: IdMap.getId("existingLine"),
title: "merge line",
description: "This is a new line",
thumbnail: "test-img-yeah.com/thumb",
content: {
unit_price: 123,
variant: {
_id: IdMap.getId("can-cover"),
},
product: {
_id: IdMap.getId("validId"),
},
quantity: 1,
},
quantity: 10,
},
],
region: IdMap.getId("region-france"),
customer_id: IdMap.getId("test-customer"),
payment_method: {
provider_id: "default_provider",
},
shipping_methods: [
{
_id: IdMap.getId("expensiveShipping"),
name: "Expensive Shipping",
price: 100,
provider_id: "default_provider",
profile_id: IdMap.getId("default"),
},
{
_id: IdMap.getId("freeShipping"),
name: "Free Shipping",
price: 10,
provider_id: "default_provider",
profile_id: IdMap.getId("profile1"),
},
],
fulfillment_status: "fulfilled",
payment_status: "captured",
status: "completed",
},
orderToRefund: {
_id: IdMap.getId("refund-order"),
email: "oliver@test.dk",
billing_address: {
first_name: "Oli",
last_name: "Medusa",
address_1: "testaddress",
city: "LA",
country_code: "US",
postal_code: "90002",
},
shipping_address: {
first_name: "Oli",
last_name: "Medusa",
address_1: "testaddress",
city: "LA",
country_code: "US",
postal_code: "90002",
},
items: [
{
_id: IdMap.getId("existingLine"),
title: "merge line",
description: "This is a new line",
thumbnail: "test-img-yeah.com/thumb",
content: {
unit_price: 100,
variant: {
_id: IdMap.getId("eur-8-us-10"),
},
product: {
_id: IdMap.getId("product"),
},
quantity: 1,
},
quantity: 10,
},
{
_id: IdMap.getId("existingLine2"),
title: "merge line",
description: "This is a new line",
thumbnail: "test-img-yeah.com/thumb",
content: {
unit_price: 100,
variant: {
_id: IdMap.getId("can-cover"),
},
product: {
_id: IdMap.getId("product"),
},
quantity: 1,
},
quantity: 10,
},
],
region: IdMap.getId("region-france"),
customer_id: IdMap.getId("test-customer"),
payment_method: {
provider_id: "default_provider",
},
shipping_methods: [
{
provider_id: "default_provider",
profile_id: IdMap.getId("default"),
data: {},
items: {},
},
{
provider_id: "default_provider",
profile_id: IdMap.getId("default"),
data: {},
items: {},
},
],
discounts: [],
},
}
export const OrderModelMock = {
create: jest.fn().mockReturnValue(Promise.resolve()),
updateOne: jest.fn().mockImplementation((query, update) => {
return Promise.resolve()
}),
deleteOne: jest.fn().mockReturnValue(Promise.resolve()),
findOne: jest.fn().mockImplementation(query => {
if (query._id === IdMap.getId("test-order")) {
orders.testOrder.payment_status = "awaiting"
return Promise.resolve(orders.testOrder)
}
if (query._id === IdMap.getId("not-fulfilled-order")) {
orders.testOrder.fulfillment_status = "not_fulfilled"
orders.testOrder.payment_status = "awaiting"
return Promise.resolve(orders.testOrder)
}
if (query._id === IdMap.getId("fulfilled-order")) {
orders.testOrder.fulfillment_status = "fulfilled"
return Promise.resolve(orders.testOrder)
}
if (query._id === IdMap.getId("payed-order")) {
orders.testOrder.fulfillment_status = "not_fulfilled"
orders.testOrder.payment_status = "captured"
return Promise.resolve(orders.testOrder)
}
if (query._id === IdMap.getId("processed-order")) {
return Promise.resolve(orders.processedOrder)
}
if (query._id === IdMap.getId("order-refund")) {
orders.orderToRefund.payment_status = "captured"
return Promise.resolve(orders.orderToRefund)
}
return Promise.resolve(undefined)
}),
}

View File

@@ -1,6 +1,3 @@
/*******************************************************************************
*
******************************************************************************/
import mongoose from "mongoose"
import { BaseModel } from "medusa-interfaces"
@@ -8,19 +5,24 @@ import LineItemSchema from "./schemas/line-item"
import PaymentMethodSchema from "./schemas/payment-method"
import ShippingMethodSchema from "./schemas/shipping-method"
import AddressSchema from "./schemas/address"
import DiscountModel from "./discount"
class OrderModel extends BaseModel {
static modelName = "Order"
static schema = {
canceled: { type: Boolean, default: false },
archived: { type: Boolean, default: false },
// pending, completed, archived, cancelled
status: { type: String, default: "pending" },
// not_fulfilled, partially_fulfilled (some line items have been returned), fulfilled, returned,
fulfillment_status: { type: String, default: "not_fulfilled" },
// awaiting, captured, refunded
payment_status: { type: String, default: "awaiting" },
email: { type: String, required: true },
billing_address: { type: AddressSchema, required: true },
shipping_address: { type: AddressSchema, required: true },
items: { type: [LineItemSchema], required: true },
region: { type: String, required: true },
discounts: { type: [String], default: [] },
discounts: { type: [DiscountModel.schema], default: [] },
customer_id: { type: String, required: true },
payment_method: { type: PaymentMethodSchema, required: true },
shipping_methods: { type: [ShippingMethodSchema], required: true },

View File

@@ -33,5 +33,6 @@ export default new mongoose.Schema({
// cards etc. the unit_price field is provided to give more granular control.
content: { type: mongoose.Schema.Types.Mixed, required: true },
quantity: { type: Number, required: true },
returned: { type: Boolean, default: false },
metadata: { type: mongoose.Schema.Types.Mixed, default: {} },
})

View File

@@ -2,6 +2,8 @@ import mongoose from "mongoose"
export default new mongoose.Schema({
provider_id: { type: String, required: true },
profile_id: { type: String, required: true },
price: { type: Number, required: true },
data: { type: mongoose.Schema.Types.Mixed, default: {} },
items: { type: [mongoose.Schema.Types.Mixed], default: [] },
})

View File

@@ -10,6 +10,7 @@ class ShippingOptionModel extends BaseModel {
name: { type: String, required: true },
region_id: { type: String, required: true },
provider_id: { type: String, required: true },
profile_id: { type: String, required: true },
data: { type: mongoose.Schema.Types.Mixed, default: {} },
price: { type: ShippingOptionPrice, required: true },
requirements: { type: [ShippingOptionRequirement], default: [] },

View File

@@ -0,0 +1,182 @@
import { IdMap } from "medusa-test-utils"
export const orders = {
testOrder: {
_id: IdMap.getId("test-order"),
email: "virgil@vandijk.dk",
billing_address: {
first_name: "Virgil",
last_name: "Van Dijk",
address_1: "24 Dunks Drive",
city: "Los Angeles",
country_code: "US",
province: "CA",
postal_code: "93011",
},
shipping_address: {
first_name: "Virgil",
last_name: "Van Dijk",
address_1: "24 Dunks Drive",
city: "Los Angeles",
country_code: "US",
province: "CA",
postal_code: "93011",
},
items: [
{
_id: IdMap.getId("existingLine"),
title: "merge line",
description: "This is a new line",
thumbnail: "test-img-yeah.com/thumb",
content: {
unit_price: 123,
variant: {
_id: IdMap.getId("can-cover"),
},
product: {
_id: IdMap.getId("validId"),
},
quantity: 1,
},
quantity: 10,
},
],
region: IdMap.getId("testRegion"),
customer_id: IdMap.getId("testCustomer"),
payment_method: {
provider_id: "default_provider",
data: {},
},
shipping_method: [
{
provider_id: "default_provider",
profile_id: IdMap.getId("validId"),
data: {},
items: {},
},
],
},
processedOrder: {
_id: IdMap.getId("processed-order"),
email: "oliver@test.dk",
billing_address: {
first_name: "Oli",
last_name: "Medusa",
address_1: "testaddress",
city: "LA",
country_code: "US",
postal_code: "90002",
},
shipping_address: {
first_name: "Oli",
last_name: "Medusa",
address_1: "testaddress",
city: "LA",
country_code: "US",
postal_code: "90002",
},
items: [
{
_id: IdMap.getId("existingLine"),
title: "merge line",
description: "This is a new line",
thumbnail: "test-img-yeah.com/thumb",
content: {
unit_price: 123,
variant: {
_id: IdMap.getId("can-cover"),
},
product: {
_id: IdMap.getId("validId"),
},
quantity: 1,
},
quantity: 10,
},
],
region: IdMap.getId("region-france"),
customer_id: IdMap.getId("test-customer"),
payment_method: {
provider_id: "default_provider",
},
shipping_methods: [
{
_id: IdMap.getId("expensiveShipping"),
name: "Expensive Shipping",
price: 100,
provider_id: "default_provider",
profile_id: IdMap.getId("default"),
},
{
_id: IdMap.getId("freeShipping"),
name: "Free Shipping",
price: 10,
provider_id: "default_provider",
profile_id: IdMap.getId("profile1"),
},
],
fulfillment_status: "fulfilled",
payment_status: "captured",
status: "completed",
},
}
export const OrderServiceMock = {
create: jest.fn().mockImplementation(data => {
return Promise.resolve(orders.testOrder)
}),
update: jest.fn().mockImplementation(data => Promise.resolve()),
retrieve: jest.fn().mockImplementation(orderId => {
if (orderId === IdMap.getId("test-order")) {
return Promise.resolve(orders.testOrder)
}
if (orderId === IdMap.getId("processed-order")) {
return Promise.resolve(orders.processedOrder)
}
return Promise.resolve(undefined)
}),
decorate: jest.fn().mockImplementation(order => {
order.decorated = true
return order
}),
cancel: jest.fn().mockImplementation(order => {
if (order === IdMap.getId("test-order")) {
orders.testOrder.status = "cancelled"
return Promise.resolve(orders.testOrder)
}
return Promise.resolve(undefined)
}),
archive: jest.fn().mockImplementation(order => {
if (order === IdMap.getId("processed-order")) {
orders.processedOrder.status = "archived"
return Promise.resolve(orders.processedOrder)
}
return Promise.resolve(undefined)
}),
createFulfillment: jest.fn().mockImplementation(order => {
if (order === IdMap.getId("test-order")) {
orders.testOrder.fulfillment_status = "fulfilled"
return Promise.resolve(orders.testOrder)
}
return Promise.resolve(undefined)
}),
capturePayment: jest.fn().mockImplementation(order => {
if (order === IdMap.getId("test-order")) {
orders.testOrder.payment_status = "captured"
return Promise.resolve(orders.testOrder)
}
return Promise.resolve(undefined)
}),
return: jest.fn().mockImplementation(order => {
if (order === IdMap.getId("test-order")) {
return Promise.resolve(orders.testOrder)
}
return Promise.resolve(undefined)
}),
}
const mock = jest.fn().mockImplementation(() => {
return OrderServiceMock
})
export default mock

View File

@@ -10,6 +10,8 @@ export const DefaultProviderMock = {
return Promise.resolve("initial")
}),
capturePayment: jest.fn().mockReturnValue(Promise.resolve()),
refundPayment: jest.fn().mockReturnValue(Promise.resolve()),
}
export const PaymentProviderServiceMock = {

View File

@@ -17,6 +17,7 @@ export const regions = {
payment_providers: ["default_provider", "france-provider"],
fulfillment_providers: ["default_provider"],
currency_code: "eur",
tax_rate: 0.25,
},
regionUs: {
_id: IdMap.getId("region-us"),

View File

@@ -4,7 +4,13 @@ export const profiles = {
default: {
_id: IdMap.getId("default"),
name: "default_profile",
products: [],
products: [IdMap.getId("product")],
shipping_options: [],
},
other: {
_id: IdMap.getId("profile1"),
name: "other_profile",
products: [IdMap.getId("product")],
shipping_options: [],
},
}
@@ -16,7 +22,13 @@ export const ShippingProfileServiceMock = {
create: jest.fn().mockImplementation(data => {
return Promise.resolve(data)
}),
retrieve: jest.fn().mockImplementation(profileId => {
retrieve: jest.fn().mockImplementation(data => {
if (data === IdMap.getId("default")) {
return Promise.resolve(profiles.default)
}
if (data === IdMap.getId("profile1")) {
return Promise.resolve(profiles.other)
}
return Promise.resolve()
}),
list: jest.fn().mockImplementation(selector => {

View File

@@ -10,6 +10,12 @@ export const TotalsServiceMock = {
}
return 0
}),
getRefundTotal: jest.fn().mockImplementation((order, lineItems) => {
if (order._id === IdMap.getId("processed-order")) {
return 1230
}
return 0
}),
}
const mock = jest.fn().mockImplementation(() => {

View File

@@ -0,0 +1,460 @@
import { IdMap } from "medusa-test-utils"
import { OrderModelMock, orders } from "../../models/__mocks__/order"
import OrderService from "../order"
import { PaymentProviderServiceMock } from "../__mocks__/payment-provider"
import { FulfillmentProviderServiceMock } from "../__mocks__/fulfillment-provider"
import { ShippingProfileServiceMock } from "../__mocks__/shipping-profile"
import { TotalsServiceMock } from "../__mocks__/totals"
describe("OrderService", () => {
describe("create", () => {
const orderService = new OrderService({
orderModel: OrderModelMock,
})
beforeEach(async () => {
jest.clearAllMocks()
})
it("calls order model functions", async () => {
await orderService.create({
email: "oliver@test.dk",
})
expect(OrderModelMock.create).toHaveBeenCalledTimes(1)
expect(OrderModelMock.create).toHaveBeenCalledWith({
email: "oliver@test.dk",
})
})
})
describe("retrieve", () => {
let result
const orderService = new OrderService({
orderModel: OrderModelMock,
})
beforeAll(async () => {
jest.clearAllMocks()
result = await orderService.retrieve(IdMap.getId("test-order"))
})
it("calls order model functions", async () => {
expect(OrderModelMock.findOne).toHaveBeenCalledTimes(1)
expect(OrderModelMock.findOne).toHaveBeenCalledWith({
_id: IdMap.getId("test-order"),
})
})
it("returns correct order", async () => {
expect(result._id).toEqual(IdMap.getId("test-order"))
})
})
describe("update", () => {
const orderService = new OrderService({
orderModel: OrderModelMock,
})
beforeEach(async () => {
jest.clearAllMocks()
})
it("calls order model functions", async () => {
await orderService.update(IdMap.getId("test-order"), {
email: "oliver@test.dk",
})
expect(OrderModelMock.updateOne).toHaveBeenCalledTimes(1)
expect(OrderModelMock.updateOne).toHaveBeenCalledWith(
{ _id: IdMap.getId("test-order") },
{
$set: {
email: "oliver@test.dk",
},
},
{ runValidators: true }
)
})
it("throws on invalid billing address", async () => {
const address = {
last_name: "James",
address_1: "24 Dunks Drive",
city: "Los Angeles",
country_code: "US",
province: "CA",
postal_code: "93011",
}
try {
await orderService.update(IdMap.getId("test-order"), {
billing_address: address,
})
} catch (err) {
expect(err.message).toEqual("The address is not valid")
}
expect(OrderModelMock.updateOne).toHaveBeenCalledTimes(0)
})
it("throws on invalid shipping address", async () => {
const address = {
last_name: "James",
address_1: "24 Dunks Drive",
city: "Los Angeles",
country_code: "US",
province: "CA",
postal_code: "93011",
}
try {
await orderService.update(IdMap.getId("test-order"), {
shipping_address: address,
})
} catch (err) {
expect(err.message).toEqual("The address is not valid")
}
expect(OrderModelMock.updateOne).toHaveBeenCalledTimes(0)
})
it("throws if metadata update are attempted", async () => {
try {
await orderService.update(IdMap.getId("test-order"), {
metadata: { test: "foo" },
})
} catch (error) {
expect(error.message).toEqual(
"Use setMetadata to update metadata fields"
)
}
})
it("throws if address updates are attempted after fulfillment", async () => {
try {
await orderService.update(IdMap.getId("fulfilled-order"), {
billing_address: {
first_name: "Lebron",
last_name: "James",
address_1: "24 Dunks Drive",
city: "Los Angeles",
country_code: "US",
province: "CA",
postal_code: "93011",
},
})
} catch (error) {
expect(error.message).toEqual(
"Can't update shipping, billing, items and payment method when order is processed"
)
}
})
it("throws if payment method update is attempted after fulfillment", async () => {
try {
await orderService.update(IdMap.getId("fulfilled-order"), {
payment_method: {
provider_id: "test",
profile_id: "test",
},
})
} catch (error) {
expect(error.message).toEqual(
"Can't update shipping, billing, items and payment method when order is processed"
)
}
})
it("throws if items update is attempted after fulfillment", async () => {
try {
await orderService.update(IdMap.getId("fulfilled-order"), {
items: [],
})
} catch (error) {
expect(error.message).toEqual(
"Can't update shipping, billing, items and payment method when order is processed"
)
}
})
})
describe("cancel", () => {
const orderService = new OrderService({
orderModel: OrderModelMock,
})
beforeEach(async () => {
jest.clearAllMocks()
})
it("calls order model functions", async () => {
await orderService.cancel(IdMap.getId("not-fulfilled-order"))
expect(OrderModelMock.updateOne).toHaveBeenCalledTimes(1)
expect(OrderModelMock.updateOne).toHaveBeenCalledWith(
{ _id: IdMap.getId("not-fulfilled-order") },
{ $set: { status: "cancelled" } }
)
})
it("throws if order is fulfilled", async () => {
try {
await orderService.cancel(IdMap.getId("fulfilled-order"))
} catch (error) {
expect(error.message).toEqual("Can't cancel a fulfilled order")
}
})
it("throws if order payment is captured", async () => {
try {
await orderService.cancel(IdMap.getId("payed-order"))
} catch (error) {
expect(error.message).toEqual(
"Can't cancel an order with payment processed"
)
}
})
})
describe("capturePayment", () => {
const orderService = new OrderService({
orderModel: OrderModelMock,
paymentProviderService: PaymentProviderServiceMock,
})
beforeEach(async () => {
jest.clearAllMocks()
})
it("calls order model functions", async () => {
await orderService.capturePayment(IdMap.getId("test-order"))
expect(OrderModelMock.updateOne).toHaveBeenCalledTimes(1)
expect(OrderModelMock.updateOne).toHaveBeenCalledWith(
{ _id: IdMap.getId("test-order") },
{ $set: { payment_status: "captured" } }
)
})
it("throws if payment is already processed", async () => {
try {
await orderService.capturePayment(IdMap.getId("payed-order"))
} catch (error) {
expect(error.message).toEqual("Payment already captured")
}
})
})
describe("createFulfillment", () => {
const orderService = new OrderService({
orderModel: OrderModelMock,
paymentProviderService: PaymentProviderServiceMock,
fulfillmentProviderService: FulfillmentProviderServiceMock,
shippingProfileService: ShippingProfileServiceMock,
})
beforeEach(async () => {
jest.clearAllMocks()
})
it("calls order model functions", async () => {
await orderService.createFulfillment(IdMap.getId("test-order"))
expect(OrderModelMock.updateOne).toHaveBeenCalledTimes(1)
expect(OrderModelMock.updateOne).toHaveBeenCalledWith(
{ _id: IdMap.getId("test-order") },
{ $set: { fulfillment_status: "fulfilled" } }
)
})
it("throws if payment is already processed", async () => {
try {
await orderService.createFulfillment(IdMap.getId("fulfilled-order"))
} catch (error) {
expect(error.message).toEqual("Order is already fulfilled")
}
})
})
describe("return", () => {
const orderService = new OrderService({
orderModel: OrderModelMock,
paymentProviderService: PaymentProviderServiceMock,
totalsService: TotalsServiceMock,
})
beforeEach(async () => {
jest.clearAllMocks()
})
it("calls order model functions", async () => {
await orderService.return(IdMap.getId("processed-order"), [
{
_id: IdMap.getId("existingLine"),
title: "merge line",
description: "This is a new line",
thumbnail: "test-img-yeah.com/thumb",
content: {
unit_price: 123,
variant: {
_id: IdMap.getId("can-cover"),
},
product: {
_id: IdMap.getId("validId"),
},
quantity: 1,
},
quantity: 10,
},
])
expect(OrderModelMock.updateOne).toHaveBeenCalledTimes(1)
expect(OrderModelMock.updateOne).toHaveBeenCalledWith(
{ _id: IdMap.getId("processed-order") },
{
$set: {
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",
quantity: 10,
returned_quantity: 10,
thumbnail: "test-img-yeah.com/thumb",
title: "merge line",
},
],
fulfillment_status: "returned",
},
}
)
})
it("calls order model functions and sets partially_fulfilled", async () => {
await orderService.return(IdMap.getId("order-refund"), [
{
_id: IdMap.getId("existingLine"),
title: "merge line",
description: "This is a new line",
thumbnail: "test-img-yeah.com/thumb",
content: {
unit_price: 100,
variant: {
_id: IdMap.getId("eur-8-us-10"),
},
product: {
_id: IdMap.getId("product"),
},
quantity: 1,
},
quantity: 2,
},
])
expect(OrderModelMock.updateOne).toHaveBeenCalledTimes(1)
expect(OrderModelMock.updateOne).toHaveBeenCalledWith(
{ _id: IdMap.getId("order-refund") },
{
$set: {
items: [
{
_id: IdMap.getId("existingLine"),
content: {
product: {
_id: IdMap.getId("product"),
},
quantity: 1,
unit_price: 100,
variant: {
_id: IdMap.getId("eur-8-us-10"),
},
},
description: "This is a new line",
quantity: 10,
returned_quantity: 2,
thumbnail: "test-img-yeah.com/thumb",
title: "merge line",
},
{
_id: IdMap.getId("existingLine2"),
title: "merge line",
description: "This is a new line",
thumbnail: "test-img-yeah.com/thumb",
content: {
unit_price: 100,
variant: {
_id: IdMap.getId("can-cover"),
},
product: {
_id: IdMap.getId("product"),
},
quantity: 1,
},
quantity: 10,
},
],
fulfillment_status: "partially_fulfilled",
},
}
)
})
it("throws if payment is already processed", async () => {
try {
await orderService.return(IdMap.getId("fulfilled-order"))
} catch (error) {
expect(error.message).toEqual(
"Can't return an order with payment unprocessed"
)
}
})
it("throws if return is attempted on unfulfilled order", async () => {
try {
await orderService.return(IdMap.getId("not-fulfilled-order"))
} catch (error) {
expect(error.message).toEqual(
"Can't return an unfulfilled or already returned order"
)
}
})
})
describe("archive", () => {
const orderService = new OrderService({
orderModel: OrderModelMock,
})
beforeEach(async () => {
jest.clearAllMocks()
})
it("calls order model functions", async () => {
await orderService.archive(IdMap.getId("processed-order"))
expect(OrderModelMock.updateOne).toHaveBeenCalledTimes(1)
expect(OrderModelMock.updateOne).toHaveBeenCalledWith(
{ _id: IdMap.getId("processed-order") },
{ $set: { status: "archived" } }
)
})
it("throws if order is unprocessed", async () => {
try {
await orderService.archive(IdMap.getId("test-order"))
} catch (error) {
expect(error.message).toEqual("Can't archive an unprocessed order")
}
})
})
})

View File

@@ -2,7 +2,9 @@ import TotalsService from "../totals"
import { ProductVariantServiceMock } from "../__mocks__/product-variant"
import { discounts } from "../../models/__mocks__/discount"
import { carts } from "../__mocks__/cart"
import { orders } from "../../models/__mocks__/order"
import { IdMap } from "medusa-test-utils"
import { RegionServiceMock } from "../__mocks__/region"
describe("TotalsService", () => {
describe("getAllocationItemDiscounts", () => {
@@ -23,9 +25,25 @@ describe("TotalsService", () => {
expect(res).toEqual([
{
lineItem: IdMap.getId("existingLine"),
lineItem: {
_id: IdMap.getId("existingLine"),
title: "merge line",
description: "This is a new line",
thumbnail: "test-img-yeah.com/thumb",
content: {
unit_price: 10,
variant: {
_id: IdMap.getId("eur-10-us-12"),
},
product: {
_id: IdMap.getId("product"),
},
quantity: 1,
},
quantity: 10,
},
variant: IdMap.getId("eur-10-us-12"),
amount: 1,
amount: 10,
},
])
})
@@ -38,9 +56,25 @@ describe("TotalsService", () => {
expect(res).toEqual([
{
lineItem: IdMap.getId("existingLine"),
lineItem: {
_id: IdMap.getId("existingLine"),
title: "merge line",
description: "This is a new line",
thumbnail: "test-img-yeah.com/thumb",
content: {
unit_price: 10,
variant: {
_id: IdMap.getId("eur-10-us-12"),
},
product: {
_id: IdMap.getId("product"),
},
quantity: 1,
},
quantity: 10,
},
variant: IdMap.getId("eur-10-us-12"),
amount: 9,
amount: 90,
},
])
})
@@ -70,41 +104,41 @@ describe("TotalsService", () => {
carts.discountCart.discounts.push(discounts.total10Percent)
res = await totalsService.getDiscountTotal(carts.discountCart)
expect(res).toEqual(252)
expect(res).toEqual(28)
})
it("calculate item fixed discount", async () => {
carts.discountCart.discounts.push(discounts.item2Fixed)
res = await totalsService.getDiscountTotal(carts.discountCart)
expect(res).toEqual(278)
expect(res).toEqual(20)
})
it("calculate item percentage discount", async () => {
carts.discountCart.discounts.push(discounts.item10Percent)
res = await totalsService.getDiscountTotal(carts.discountCart)
expect(res).toEqual(279)
expect(res).toEqual(10)
})
it("calculate total fixed discount", async () => {
carts.discountCart.discounts.push(discounts.total10Fixed)
res = await totalsService.getDiscountTotal(carts.discountCart)
expect(res).toEqual(270)
expect(res).toEqual(10)
})
it("ignores discount if expired", async () => {
carts.discountCart.discounts.push(discounts.expiredDiscount)
res = await totalsService.getDiscountTotal(carts.discountCart)
expect(res).toEqual(280)
expect(res).toEqual(0)
})
it("returns cart subtotal if no discounts are applied", async () => {
it("returns 0 if no discounts are applied", async () => {
res = await totalsService.getDiscountTotal(carts.discountCart)
expect(res).toEqual(280)
expect(res).toEqual(0)
})
it("returns 0 if no items are in cart", async () => {
@@ -113,4 +147,219 @@ describe("TotalsService", () => {
expect(res).toEqual(0)
})
})
describe("getRefundTotal", () => {
let res
const totalsService = new TotalsService({
productVariantService: ProductVariantServiceMock,
regionService: RegionServiceMock,
})
beforeEach(() => {
jest.clearAllMocks()
orders.orderToRefund.discounts = []
})
it("calculates refund", async () => {
res = await totalsService.getRefundTotal(orders.orderToRefund, [
{
_id: IdMap.getId("existingLine"),
title: "merge line",
description: "This is a new line",
thumbnail: "test-img-yeah.com/thumb",
content: {
unit_price: 123,
variant: {
_id: IdMap.getId("can-cover"),
},
product: {
_id: IdMap.getId("product"),
},
quantity: 1,
},
quantity: 10,
},
])
expect(res).toEqual(1537.5)
})
it("calculates refund with total precentage discount", async () => {
orders.orderToRefund.discounts.push(discounts.total10Percent)
res = await totalsService.getRefundTotal(orders.orderToRefund, [
{
_id: IdMap.getId("existingLine"),
title: "merge line",
description: "This is a new line",
thumbnail: "test-img-yeah.com/thumb",
content: {
unit_price: 123,
variant: {
_id: IdMap.getId("can-cover"),
},
product: {
_id: IdMap.getId("product"),
},
quantity: 1,
},
quantity: 10,
},
])
expect(res).toEqual(1107)
})
it("calculates refund with total fixed discount", async () => {
orders.orderToRefund.discounts.push(discounts.total10Fixed)
res = await totalsService.getRefundTotal(orders.orderToRefund, [
{
_id: IdMap.getId("existingLine"),
title: "merge line",
description: "This is a new line",
thumbnail: "test-img-yeah.com/thumb",
content: {
unit_price: 123,
variant: {
_id: IdMap.getId("can-cover"),
},
product: {
_id: IdMap.getId("product"),
},
quantity: 1,
},
quantity: 3,
},
])
expect(res).toEqual(359)
})
it("calculates refund with item fixed discount", async () => {
orders.orderToRefund.discounts.push(discounts.item2Fixed)
res = await totalsService.getRefundTotal(orders.orderToRefund, [
{
_id: IdMap.getId("existingLine"),
title: "merge line",
description: "This is a new line",
thumbnail: "test-img-yeah.com/thumb",
content: {
unit_price: 123,
variant: {
_id: IdMap.getId("eur-8-us-10"),
},
product: {
_id: IdMap.getId("product"),
},
quantity: 1,
},
quantity: 3,
},
])
expect(res).toEqual(363)
})
it("calculates refund with item percentage discount", async () => {
orders.orderToRefund.discounts.push(discounts.item10Percent)
res = await totalsService.getRefundTotal(orders.orderToRefund, [
{
_id: IdMap.getId("existingLine"),
title: "merge line",
description: "This is a new line",
thumbnail: "test-img-yeah.com/thumb",
content: {
unit_price: 123,
variant: {
_id: IdMap.getId("eur-8-us-10"),
},
product: {
_id: IdMap.getId("product"),
},
quantity: 1,
},
quantity: 3,
},
])
expect(res).toEqual(332.1)
})
it("throws if line items to return is not in order", async () => {
try {
await totalsService.getRefundTotal(orders.orderToRefund, [
{
_id: IdMap.getId("notInOrder"),
title: "merge line",
description: "This is a new line",
thumbnail: "test-img-yeah.com/thumb",
content: {
unit_price: 123,
variant: {
_id: IdMap.getId("eur-8-us-10"),
},
product: {
_id: IdMap.getId("product"),
},
quantity: 1,
},
quantity: 3,
},
])
} catch (error) {
expect(error.message).toEqual("Line items does not exist on order")
}
})
})
describe("getShippingTotal", () => {
let res
const totalsService = new TotalsService({
productVariantService: ProductVariantServiceMock,
})
beforeEach(() => {
jest.clearAllMocks()
})
it("calculates shipping", async () => {
res = await totalsService.getShippingTotal(orders.testOrder)
expect(res).toEqual(110)
})
})
describe("getTaxTotal", () => {
let res
const totalsService = new TotalsService({
productVariantService: ProductVariantServiceMock,
regionService: RegionServiceMock,
})
beforeEach(() => {
jest.clearAllMocks()
orders.orderToRefund.discounts = []
})
it("calculates tax", async () => {
res = await totalsService.getTaxTotal(orders.testOrder)
expect(res).toEqual(335)
})
})
describe("getTotal", () => {
let res
const totalsService = new TotalsService({
productVariantService: ProductVariantServiceMock,
regionService: RegionServiceMock,
})
beforeEach(() => {
jest.clearAllMocks()
})
it("calculates total", async () => {
res = await totalsService.getTotal(orders.testOrder)
expect(res).toEqual(1230 + 335 + 110)
})
})
})

View File

@@ -725,7 +725,8 @@ class CartService extends BaseService {
* also have additional details in the data field such as an id for a package
* shop.
* @param {string} cartId - the id of the cart to add shipping method to
* @param {ShippingMethod} method - the shipping method to add to the cart
* @param {string} optionId - id of shipping option to add as valid method
* @param {Object} data - the fulmillment data for the method
* @return {Promise} the result of the update operation
*/
async addShippingMethod(cartId, optionId, data) {

View File

@@ -0,0 +1,474 @@
import _ from "lodash"
import { Validator, MedusaError } from "medusa-core-utils"
import { BaseService } from "medusa-interfaces"
class OrderService extends BaseService {
constructor({
orderModel,
paymentProviderService,
shippingProfileService,
fulfillmentProviderService,
lineItemService,
totalsService,
eventBusService,
}) {
super()
/** @private @const {OrderModel} */
this.orderModel_ = orderModel
/** @private @const {PaymentProviderService} */
this.paymentProviderService_ = paymentProviderService
/** @private @const {ShippingProvileService} */
this.shippingProfileService_ = shippingProfileService
/** @private @const {FulfillmentProviderService} */
this.fulfillmentProviderService_ = fulfillmentProviderService
/** @private @const {LineItemService} */
this.lineItemService_ = lineItemService
/** @private @const {TotalsService} */
this.totalsService_ = totalsService
/** @private @const {EventBus} */
this.eventBus_ = eventBusService
}
/**
* Used to validate order ids. Throws an error if the cast fails
* @param {string} rawId - the raw order id to validate.
* @return {string} the validated id
*/
validateId_(rawId) {
const schema = Validator.objectId()
const { value, error } = schema.validate(rawId)
if (error) {
throw new MedusaError(
MedusaError.Types.INVALID_ARGUMENT,
"The order id could not be casted to an ObjectId"
)
}
return value
}
/**
* Used to validate order addresses. Can be used to both
* validate shipping and billing address.
* @param {Address} address - the address to validate
* @return {Address} the validated address
*/
validateAddress_(address) {
const { value, error } = Validator.address().validate(address)
if (error) {
throw new MedusaError(
MedusaError.Types.INVALID_DATA,
"The address is not valid"
)
}
return value
}
/**
* Used to validate email.
* @param {string} email - the email to vaildate
* @return {string} the validate email
*/
validateEmail_(email) {
const schema = Validator.string().email()
const { value, error } = schema.validate(email)
if (error) {
throw new MedusaError(
MedusaError.Types.INVALID_ARGUMENT,
"The email is not valid"
)
}
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
}
/**
* Gets an order by id.
* @param {string} orderId - id of order to retrieve
* @return {Promise<Order>} the order document
*/
async retrieve(orderId) {
const validatedId = this.validateId_(orderId)
const order = await this.orderModel_
.findOne({ _id: validatedId })
.catch(err => {
throw new MedusaError(MedusaError.Types.DB_ERROR, err.message)
})
if (!order) {
throw new MedusaError(
MedusaError.Types.NOT_FOUND,
`Order with ${orderId} was not found`
)
}
return order
}
/**
* Creates an order
* @param {object} order - the order to create
* @return {Promise} resolves to the creation result.
*/
async create(order) {
return this.orderModel_.create(order).catch(err => {
throw new MedusaError(MedusaError.Types.DB_ERROR, err.message)
})
}
/**
* Updates an order. Metadata updates should
* use dedicated method, e.g. `setMetadata` etc. The function
* will throw errors if metadata updates are attempted.
* @param {string} orderId - the id of the order. Must be a string that
* can be casted to an ObjectId
* @param {object} update - an object with the update values.
* @return {Promise} resolves to the update result.
*/
async update(orderId, update) {
const order = await this.retrieve(orderId)
if (
(update.shipping_address ||
update.billing_address ||
update.payment_method ||
update.items) &&
(order.fulfillment_status !== "not_fulfilled" ||
order.payment_status !== "awaiting" ||
order.status !== "pending")
) {
throw new MedusaError(
MedusaError.Types.NOT_ALLOWED,
"Can't update shipping, billing, items and payment method when order is processed"
)
}
if (update.metadata) {
throw new MedusaError(
MedusaError.Types.INVALID_DATA,
"Use setMetadata to update metadata fields"
)
}
if (update.status || update.fulfillment_status || update.payment_status) {
throw new MedusaError(
MedusaError.Types.NOT_ALLOWED,
"Can't update order statuses. This will happen automatically. Use metadata in order for additional statuses"
)
}
const updateFields = { ...update }
if (update.shipping_address) {
updateFields.shipping_address = this.validateAddress_(
update.shipping_address
)
}
if (update.billing_address) {
updateFields.billing_address = this.validateAddress_(
update.billing_address
)
}
if (update.items) {
updateFields.items = update.items.map(item =>
this.lineItemService_.validate(item)
)
}
return this.orderModel_
.updateOne(
{ _id: order._id },
{ $set: updateFields },
{ runValidators: true }
)
.catch(err => {
throw new MedusaError(MedusaError.Types.DB_ERROR, err.message)
})
}
/**
* Cancels an order.
* Throws if fulfillment process has been initiated.
* Throws if payment process has been initiated.
* @param {string} orderId - id of order to cancel.
* @return {Promise} result of the update operation.
*/
async cancel(orderId) {
const order = await this.retrieve(orderId)
if (order.fulfillment_status !== "not_fulfilled") {
throw new MedusaError(
MedusaError.Types.NOT_ALLOWED,
"Can't cancel a fulfilled order"
)
}
if (order.payment_status !== "awaiting") {
throw new MedusaError(
MedusaError.Types.NOT_ALLOWED,
"Can't cancel an order with payment processed"
)
}
// TODO: cancel payment method
return this.orderModel_.updateOne(
{
_id: orderId,
},
{
$set: { status: "cancelled" },
}
)
}
/**
* Captures payment for an order.
* @param {string} orderId - id of order to capture payment for.
* @return {Promise} result of the update operation.
*/
async capturePayment(orderId) {
const order = await this.retrieve(orderId)
if (order.payment_status !== "awaiting") {
throw new MedusaError(
MedusaError.Types.INVALID_ARGUMENT,
"Payment already captured"
)
}
// prepare update object
const updateFields = { payment_status: "captured" }
const completed = order.fulfillment_status !== "not_fulfilled"
if (completed) {
updateFields.status = "completed"
}
const { provider_id, data } = order.payment_method
const paymentProvider = await this.paymentProviderService_.retrieveProvider(
provider_id
)
await paymentProvider.capturePayment(data)
return this.orderModel_.updateOne(
{
_id: orderId,
},
{
$set: updateFields,
}
)
}
/**
* 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(orderId) {
const order = await this.retrieve(orderId)
if (order.fulfillment_status !== "not_fulfilled") {
throw new MedusaError(
MedusaError.Types.INVALID_ARGUMENT,
"Order is already fulfilled"
)
}
const { shipping_methods, items } = order
// prepare update object
const updateFields = { fulfillment_status: "fulfilled" }
const completed = order.payment_status !== "awaiting"
if (completed) {
updateFields.status = "completed"
}
// partition order items to their dedicated shipping method
order.shipping_methods = await this.partitionItems_(shipping_methods, items)
await Promise.all(
order.shipping_methods.map(method => {
const provider = this.fulfillmentProviderService_.retrieveProvider(
method.provider_id
)
provider.createOrder(method.data, method.items)
})
)
return this.orderModel_.updateOne(
{
_id: orderId,
},
{
$set: updateFields,
}
)
}
/**
* Return either the entire or part of an order.
* @param {string} orderId - the order to return.
* @param {string[]} lineItems - the line items to return
* @return {Promise} the result of the update operation
*/
async return(orderId, lineItems) {
const order = await this.retrieve(orderId)
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"
)
}
const { provider_id, data } = order.payment_method
const paymentProvider = this.paymentProviderService_.retrieveProvider(
provider_id
)
const amount = this.totalsService_.getRefundTotal(order, lineItems)
await paymentProvider.refundPayment(data, amount)
lineItems.map(item => {
const returnedItem = order.items.find(({ _id }) => _id === item._id)
if (returnedItem) {
returnedItem.returned_quantity = item.quantity
}
})
const fullReturn = order.items.every(
item => item.quantity === item.returned_quantity
)
return this.orderModel_.updateOne(
{
_id: orderId,
},
{
$set: {
items: order.items,
fulfillment_status: fullReturn ? "returned" : "partially_fulfilled",
},
}
)
}
/**
* Archives an order. It only alloved, if the order has been fulfilled
* and payment has been captured.
* @param {string} orderId - the order to archive
* @return {Promise} the result of the update operation
*/
async archive(orderId) {
const order = await this.retrieve(orderId)
if (order.status !== ("completed" || "refunded")) {
throw new MedusaError(
MedusaError.Types.NOT_ALLOWED,
"Can't archive an unprocessed order"
)
}
return this.orderModel_.updateOne(
{
_id: orderId,
},
{
$set: { status: "archived" },
}
)
}
/**
* Decorates an order.
* @param {Order} order - the order to decorate.
* @param {string[]} fields - the fields to include.
* @param {string[]} expandFields - fields to expand.
* @return {Order} return the decorated order.
*/
async decorate(order, fields, expandFields = []) {
const requiredFields = ["_id", "metadata"]
const decorated = _.pick(order, fields.concat(requiredFields))
return decorated
}
/**
* Dedicated method to set metadata for an order.
* To ensure that plugins does not overwrite each
* others metadata fields, setMetadata is provided.
* @param {string} orderId - the order to decorate.
* @param {string} key - key for metadata field
* @param {string} value - value for metadata field.
* @return {Promise} resolves to the updated result.
*/
setMetadata(orderId, key, value) {
const validatedId = this.validateId_(orderId)
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.orderModel_
.updateOne({ _id: validatedId }, { $set: { [keyPath]: value } })
.catch(err => {
throw new MedusaError(MedusaError.Types.DB_ERROR, err.message)
})
}
}
export default OrderService

View File

@@ -1,28 +1,47 @@
import _ from "lodash"
import { BaseService } from "medusa-interfaces"
import { MedusaError } from "medusa-core-utils"
/**
* A service that calculates total and subtotals for orders, carts etc..
* @implements BaseService
*/
class TotalsService extends BaseService {
constructor({ productVariantService }) {
constructor({ productVariantService, regionService }) {
super()
/** @private @const {ProductVariantService} */
this.productVariantService_ = productVariantService
/** @private @const {RegionService} */
this.regionService_ = regionService
}
/**
* Calculates subtotal of a given cart
* @param {Cart} Cart - the cart to calculate subtotal for
* Calculates subtotal of a given cart or order.
* @param {Cart || Order} object - cart or order to calculate subtotal for
* @return {int} the calculated subtotal
*/
getSubtotal(cart) {
async getTotal(object) {
const subtotal = this.getSubtotal(object)
const taxTotal = await this.getTaxTotal(object)
const discountTotal = await this.getDiscountTotal(object)
const shippingTotal = this.getShippingTotal(object)
return subtotal + taxTotal + shippingTotal - discountTotal
}
/**
* Calculates subtotal of a given cart or order.
* @param {Cart || Order} object - cart or order to calculate subtotal for
* @return {int} the calculated subtotal
*/
getSubtotal(object) {
let subtotal = 0
if (!cart.items) {
if (!object.items) {
return subtotal
}
cart.items.map(item => {
object.items.map(item => {
if (Array.isArray(item.content)) {
const temp = _.sumBy(item.content, c => c.unit_price * c.quantity)
subtotal += temp * item.quantity
@@ -34,6 +53,137 @@ class TotalsService extends BaseService {
return subtotal
}
/**
* Calculates shipping total
* @param {Cart | Object} object - cart or order to calculate subtotal for
* @return {int} shipping total
*/
getShippingTotal(order) {
const { shipping_methods } = order
return shipping_methods.reduce((acc, next) => {
return acc + next.price
}, 0)
}
/**
* Calculates tax total
* Currently based on the Danish tax system
* @param {Cart | Object} object - cart or order to calculate subtotal for
* @return {int} tax total
*/
async getTaxTotal(object) {
const subtotal = this.getSubtotal(object)
const shippingTotal = this.getShippingTotal(object)
const region = await this.regionService_.retrieve(object.region)
const { tax_rate } = region
return (subtotal + shippingTotal) * tax_rate
}
/**
* Calculates refund total of line items.
* If any of the items to return have been discounted, we need to
* apply the discount again before refunding them.
* @param {Order} order - cart or order to calculate subtotal for
* @param {[LineItem]} lineItems -
* @return {int} the calculated subtotal
*/
async getRefundTotal(order, lineItems) {
const discount = order.discounts.find(
({ discount_rule }) => discount_rule.type !== "free_shipping"
)
if (_.differenceBy(lineItems, order.items, "_id").length !== 0) {
throw new MedusaError(
MedusaError.Types.INVALID_ARGUMENT,
"Line items does not exist on order"
)
}
const subtotal = this.getSubtotal({ items: lineItems })
const region = await this.regionService_.retrieve(order.region)
// if nothing is discounted, return the subtotal of line items
if (!discount) {
return subtotal * (1 + region.tax_rate)
}
const { value, type, allocation } = discount.discount_rule
if (type === "percentage" && allocation === "total") {
const discountTotal = (subtotal / 100) * value
return subtotal - discountTotal
}
if (type === "fixed" && allocation === "total") {
return subtotal - value
}
if (type === "percentage" && allocation === "item") {
// Find discounted items
const itemPercentageDiscounts = await this.getAllocationItemDiscounts(
discount,
{ items: lineItems },
"percentage"
)
// Find discount total by taking each discounted item, reducing it by
// its discount value. Then summing all those items together.
const discountRefundTotal = _.sumBy(
itemPercentageDiscounts,
d => d.lineItem.content.unit_price * d.lineItem.quantity - d.amount
)
// Find the items that weren't discounted
const notDiscountedItems = _.differenceBy(
lineItems,
Array.from(itemPercentageDiscounts, el => el.lineItem),
"_id"
)
// If all items were discounted, we return the total of the discounted
// items
if (!notDiscountedItems) {
return discountRefundTotal
}
// Otherwise, we find the total those not discounted
const notDiscRefundTotal = this.getSubtotal({ items: notDiscountedItems })
// Finally, return the sum of discounted and not discounted items
return notDiscRefundTotal + discountRefundTotal
}
// See immediate `if`-statement above for a elaboration on the following
// calculations. This time with fixed discount type.
if (type === "fixed" && allocation === "item") {
const itemPercentageDiscounts = await this.getAllocationItemDiscounts(
discount,
{ items: lineItems },
"fixed"
)
const discountRefundTotal = _.sumBy(
itemPercentageDiscounts,
d => d.lineItem.content.unit_price * d.lineItem.quantity - d.amount
)
const notDiscountedItems = _.differenceBy(
lineItems,
Array.from(itemPercentageDiscounts, el => el.lineItem),
"_id"
)
if (!notDiscountedItems) {
return notDiscRefundTotal
}
const notDiscRefundTotal = this.getSubtotal({ items: notDiscountedItems })
return notDiscRefundTotal + discountRefundTotal
}
}
/**
* Calculates either fixed or percentage discount of a variant
* @param {string} lineItem - id of line item
@@ -49,13 +199,16 @@ class TotalsService extends BaseService {
return {
lineItem,
variant,
amount: (variantPrice / 100) * value,
amount: ((variantPrice * lineItem.quantity) / 100) * value,
}
} else {
return {
lineItem,
variant,
amount: value >= variantPrice ? variantPrice : value,
amount:
value >= variantPrice * lineItem.quantity
? variantPrice * lineItem.quantity
: value * lineItem.quantity,
}
}
}
@@ -75,16 +228,16 @@ class TotalsService extends BaseService {
const discounts = []
for (const item of cart.items) {
if (discount.discount_rule.valid_for.length > 0) {
discount.discount_rule.valid_for.map(v => {
discount.discount_rule.valid_for.map(variant => {
// Discounts do not apply to bundles, hence:
if (Array.isArray(item.content)) {
return discounts
} else {
if (item.content.variant._id === v) {
if (item.content.variant._id === variant) {
discounts.push(
this.calculateDiscount_(
item._id,
v,
item,
variant,
item.content.unit_price,
discount.discount_rule.value,
discount.discount_rule.type
@@ -99,17 +252,16 @@ class TotalsService extends BaseService {
}
/**
* Calculates discount total of a cart using the discounts provided in the
* cart.discounts array. This will be subtracted from the cart subtotal,
* which is returned from the function.
* Calculates the total discount amount for each of the different supported
* discount types. If discounts aren't present or invalid returns 0.
* @param {Cart} Cart - the cart to calculate discounts for
* @return {int} the subtotal after discounts are applied
* @return {int} the total discounts amount
*/
async getDiscountTotal(cart) {
let subtotal = this.getSubtotal(cart)
if (!cart.discounts) {
return subtotal
return 0
}
// filter out invalid discounts
@@ -135,14 +287,13 @@ class TotalsService extends BaseService {
)
if (!discount) {
return subtotal
return 0
}
const { type, allocation, value } = discount.discount_rule
if (type === "percentage" && allocation === "total") {
subtotal -= (subtotal / 100) * value
return subtotal
return (subtotal / 100) * value
}
if (type === "percentage" && allocation === "item") {
@@ -152,13 +303,11 @@ class TotalsService extends BaseService {
"percentage"
)
const totalDiscount = _.sumBy(itemPercentageDiscounts, d => d.amount)
subtotal -= totalDiscount
return subtotal
return totalDiscount
}
if (type === "fixed" && allocation === "total") {
subtotal -= value
return subtotal
return value
}
if (type === "fixed" && allocation === "item") {
@@ -168,11 +317,10 @@ class TotalsService extends BaseService {
"fixed"
)
const totalDiscount = _.sumBy(itemFixedDiscounts, d => d.amount)
subtotal -= totalDiscount
return subtotal
return totalDiscount
}
return subtotal
return 0
}
}