Fixes merge conflicts

This commit is contained in:
olivermrbl committed 2020-08-06 12:16:50 +02:00
commit 348c9980a5
79 files changed
+2728 -850

No files matched your search

+2 -1
View File
@@ -67,7 +67,8 @@
"passport-http-bearer": "^1.0.1",
"passport-jwt": "^4.0.0",
"passport-local": "^1.0.0",
"randomatic": "^3.1.1",
"winston": "^3.2.1"
},
"gitHead": "35e0930650d5f4aedf2610749cd131ae8b7e17cc"
}
}
+33
View File
@@ -7,6 +7,39 @@ import errorHandler from "./middlewares/error-handler"
export default (container, config) => {
const app = Router()
app.post("/create-shipment/:order_id", async (req, res) => {
const orderService = req.scope.resolve("orderService")
const eventBus = req.scope.resolve("eventBusService")
const order = await orderService.retrieve(req.params.order_id)
await orderService.createShipment(order._id, {
item_ids: order.items.map(({ _id }) => `${_id}`),
tracking_number: "1234",
})
res.sendStatus(200)
})
app.post("/run-hook/:order_id/capture", async (req, res) => {
const orderService = req.scope.resolve("orderService")
const eventBus = req.scope.resolve("eventBusService")
const order = await orderService.retrieve(req.params.order_id)
eventBus.emit("order.payment_captured", order)
res.sendStatus(200)
})
app.post("/run-hook/:order_id", async (req, res) => {
const orderService = req.scope.resolve("orderService")
const eventBus = req.scope.resolve("eventBusService")
const order = await orderService.retrieve(req.params.order_id)
eventBus.emit("order.placed", order)
res.sendStatus(200)
})
admin(app, container, config)
store(app, container, config)
@@ -0,0 +1,25 @@
import { MedusaError, Validator } from "medusa-core-utils"
export default async (req, res) => {
const schema = Validator.object().keys({
application_name: Validator.string().required(),
state: Validator.string().required(),
code: Validator.string().required(),
})
const { value, error } = schema.validate(req.body)
if (error) {
throw new MedusaError(MedusaError.Types.INVALID_DATA, error.details)
}
try {
const oauthService = req.scope.resolve("oauthService")
const data = await oauthService.generateToken(
value.application_name,
value.code,
value.state
)
res.status(200).json({ apps: data })
} catch (err) {
throw err
}
}
@@ -0,0 +1,16 @@
import { Router } from "express"
import middlewares from "../../../middlewares"
const route = Router()
export default app => {
app.use("/apps", route)
route.get("/", middlewares.wrap(require("./list").default))
route.post(
"/authorizations",
middlewares.wrap(require("./authorize-app").default)
)
return app
}
@@ -0,0 +1,12 @@
import { MedusaError, Validator } from "medusa-core-utils"
export default async (req, res) => {
try {
const oauthService = req.scope.resolve("oauthService")
const data = await oauthService.list({})
res.status(200).json({ apps: data })
} catch (err) {
throw err
}
}
@@ -13,6 +13,7 @@ import orderRoutes from "./orders"
import storeRoutes from "./store"
import uploadRoutes from "./uploads"
import customerRoutes from "./customers"
import appRoutes from "./apps"
const route = Router()
@@ -40,6 +41,7 @@ export default (app, container, config) => {
// Calls all middleware that has been registered to run after authentication.
middlewareService.usePostAuthentication(app)
appRoutes(route)
productRoutes(route)
userRoutes(route)
regionRoutes(route)
@@ -14,20 +14,7 @@ describe("POST /admin/orders/:id/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,
},
item_id: IdMap.getId("existingLine"),
quantity: 10,
},
],
@@ -51,20 +38,7 @@ describe("POST /admin/orders/:id/return", () => {
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,
},
item_id: IdMap.getId("existingLine"),
quantity: 10,
},
]
@@ -4,7 +4,13 @@ export default async (req, res) => {
const { id } = req.params
const schema = Validator.object().keys({
items: Validator.array().required(),
items: Validator.array()
.items({
item_id: Validator.string().required(),
quantity: Validator.number().required(),
})
.required(),
refund: Validator.number().optional(),
})
const { value, error } = schema.validate(req.body)
@@ -3,7 +3,7 @@ import { MedusaError, Validator } from "medusa-core-utils"
export default async (req, res) => {
const schema = Validator.object().keys({
title: Validator.string().required(),
description: Validator.string(),
description: Validator.string().allow(""),
tags: Validator.string(),
is_giftcard: Validator.boolean().default(false),
options: Validator.array().items({
@@ -15,6 +15,7 @@ export default async (req, res) => {
title: Validator.string().required(),
sku: Validator.string(),
ean: Validator.string(),
barcode: Validator.string(),
prices: Validator.array()
.items({
currency_code: Validator.string().required(),
@@ -36,6 +36,7 @@ export default async (req, res) => {
[
"title",
"description",
"is_giftcard",
"tags",
"thumbnail",
"handle",
@@ -9,6 +9,7 @@ export default async (req, res) => {
[
"title",
"description",
"is_giftcard",
"tags",
"thumbnail",
"handle",
@@ -10,6 +10,7 @@ export default async (req, res) => {
[
"title",
"description",
"is_giftcard",
"tags",
"thumbnail",
"handle",
@@ -18,6 +18,13 @@ export default async (req, res) => {
title: Validator.string().optional(),
sku: Validator.string().optional(),
ean: Validator.string().optional(),
published: Validator.boolean(),
image: Validator.string()
.allow("")
.optional(),
barcode: Validator.string()
.allow("")
.optional(),
prices: Validator.array().items(
Validator.object()
.keys({
@@ -87,6 +87,7 @@ export default async (req, res) => {
"options",
"thumbnail",
"variants",
"is_giftcard",
"published",
],
["variants"]
@@ -33,13 +33,14 @@ export default async (req, res) => {
const shippingProfileService = req.scope.resolve("shippingProfileService")
// Add to default shipping profile
const { _id } = await shippingProfileService.retrieveDefault()
if (!value.profile_id) {
const { _id } = await shippingProfileService.retrieveDefault()
value.profile_id = _id
}
const data = await optionService.create({
...value,
profile_id: _id,
})
await shippingProfileService.addShippingOption(_id, data._id)
const data = await optionService.create(value)
await shippingProfileService.addShippingOption(value.profile_id, data._id)
res.status(200).json({ shipping_option: data })
} catch (err) {
+104 -56
View File
@@ -5,6 +5,7 @@ import {
PaymentService,
FulfillmentService,
FileService,
OauthService,
} from "medusa-interfaces"
import { getConfigFile, createRequireFromPath } from "medusa-core-utils"
import _ from "lodash"
@@ -16,7 +17,7 @@ import { sync as existsSync } from "fs-exists-cached"
/**
* Registers all services in the services directory
*/
export default ({ rootDirectory, container, app }) => {
export default async ({ rootDirectory, container, app }) => {
const { configModule, configFilePath } = getConfigFile(
rootDirectory,
`medusa-config`
@@ -47,14 +48,39 @@ export default ({ rootDirectory, container, app }) => {
version: createFileContentHash(process.cwd(), `**`),
})
resolved.forEach(pluginDetails => {
registerModels(pluginDetails, container)
registerServices(pluginDetails, container)
registerMedusaApi(pluginDetails, container)
registerApi(pluginDetails, app, rootDirectory)
registerCoreRouters(pluginDetails, container)
registerSubscribers(pluginDetails, container)
})
await Promise.all(
resolved.map(async pluginDetails => {
registerModels(pluginDetails, container)
await registerServices(pluginDetails, container)
registerMedusaApi(pluginDetails, container)
registerApi(pluginDetails, app, rootDirectory)
registerCoreRouters(pluginDetails, container)
registerSubscribers(pluginDetails, container)
})
)
await Promise.all(
resolved.map(async pluginDetails => runLoaders(pluginDetails, container))
)
}
async function runLoaders(pluginDetails, container) {
const loaderFiles = glob.sync(
`${pluginDetails.resolve}/loaders/[!__]*.js`,
{}
)
await Promise.all(
loaderFiles.map(async loader => {
try {
const module = require(loader).default
if (typeof module === "function") {
await module(container)
}
} catch (err) {
return Promise.resolve()
}
})
)
}
function registerMedusaApi(pluginDetails, container) {
@@ -133,58 +159,80 @@ function registerApi(pluginDetails, app, rootDirectory = "") {
* registered
* @return {void}
*/
function registerServices(pluginDetails, container) {
async function registerServices(pluginDetails, container) {
const files = glob.sync(`${pluginDetails.resolve}/services/[!__]*`, {})
files.forEach(fn => {
const loaded = require(fn).default
const name = formatRegistrationName(fn)
await Promise.all(
files.map(async fn => {
const loaded = require(fn).default
const name = formatRegistrationName(fn)
if (!(loaded.prototype instanceof BaseService)) {
const logger = container.resolve("logger")
const message = `Services must inherit from BaseService, please check ${fn}`
logger.error(message)
throw new Error(message)
}
if (!(loaded.prototype instanceof BaseService)) {
const logger = container.resolve("logger")
const message = `Services must inherit from BaseService, please check ${fn}`
logger.error(message)
throw new Error(message)
}
if (loaded.prototype instanceof PaymentService) {
// Register our payment providers to paymentProviders
container.registerAdd(
"paymentProviders",
asFunction(cradle => new loaded(cradle, pluginDetails.options))
)
if (loaded.prototype instanceof PaymentService) {
// Register our payment providers to paymentProviders
container.registerAdd(
"paymentProviders",
asFunction(cradle => new loaded(cradle, pluginDetails.options))
)
// Add the service directly to the container in order to make simple
// resolution if we already know which payment provider we need to use
container.register({
[name]: asFunction(cradle => new loaded(cradle, pluginDetails.options)),
[`pp_${loaded.identifier}`]: aliasTo(name),
})
} else if (loaded.prototype instanceof FulfillmentService) {
// Register our payment providers to paymentProviders
container.registerAdd(
"fulfillmentProviders",
asFunction(cradle => new loaded(cradle, pluginDetails.options))
)
// Add the service directly to the container in order to make simple
// resolution if we already know which payment provider we need to use
container.register({
[name]: asFunction(
cradle => new loaded(cradle, pluginDetails.options)
),
[`pp_${loaded.identifier}`]: aliasTo(name),
})
} else if (loaded.prototype instanceof OauthService) {
const oauthService = container.resolve("oauthService")
// Add the service directly to the container in order to make simple
// resolution if we already know which payment provider we need to use
container.register({
[name]: asFunction(cradle => new loaded(cradle, pluginDetails.options)),
[`fp_${loaded.identifier}`]: aliasTo(name),
})
} else if (loaded.prototype instanceof FileService) {
// Add the service directly to the container in order to make simple
// resolution if we already know which payment provider we need to use
container.register({
[name]: asFunction(cradle => new loaded(cradle, pluginDetails.options)),
[`fileService`]: aliasTo(name),
})
} else {
container.register({
[name]: asFunction(cradle => new loaded(cradle, pluginDetails.options)),
})
}
})
const appDetails = loaded.getAppDetails(pluginDetails.options)
await oauthService.registerOauthApp(appDetails)
const name = appDetails.application_name
container.register({
[`${name}Oauth`]: asFunction(
cradle => new loaded(cradle, pluginDetails.options)
),
})
} else if (loaded.prototype instanceof FulfillmentService) {
// Register our payment providers to paymentProviders
container.registerAdd(
"fulfillmentProviders",
asFunction(cradle => new loaded(cradle, pluginDetails.options))
)
// Add the service directly to the container in order to make simple
// resolution if we already know which payment provider we need to use
container.register({
[name]: asFunction(
cradle => new loaded(cradle, pluginDetails.options)
),
[`fp_${loaded.identifier}`]: aliasTo(name),
})
} else if (loaded.prototype instanceof FileService) {
// Add the service directly to the container in order to make simple
// resolution if we already know which payment provider we need to use
container.register({
[name]: asFunction(
cradle => new loaded(cradle, pluginDetails.options)
),
[`fileService`]: aliasTo(name),
})
} else {
container.register({
[name]: asFunction(
cradle => new loaded(cradle, pluginDetails.options)
),
})
}
})
)
}
/**
@@ -41,6 +41,7 @@ export const carts = {
cartWithPaySessionsDifRegion: {
_id: IdMap.getId("cartWithPaySessionsDifRegion"),
region_id: IdMap.getId("region-france"),
total: 1,
items: [
{
_id: IdMap.getId("existingLine"),
@@ -81,6 +82,7 @@ export const carts = {
},
cartWithPaySessions: {
_id: IdMap.getId("cartWithPaySessions"),
total: 1,
region_id: IdMap.getId("testRegion"),
shipping_methods: [],
items: [
@@ -123,6 +125,7 @@ export const carts = {
},
cartWithLine: {
_id: IdMap.getId("cartWithLine"),
total: 1,
title: "test",
region_id: IdMap.getId("testRegion"),
items: [
@@ -166,6 +169,92 @@ export const carts = {
discounts: [],
customer_id: "",
},
withGiftCard: {
_id: IdMap.getId("withGiftCard"),
region_id: IdMap.getId("region-france"),
items: [
{
_id: IdMap.getId("existingLine"),
title: "merge line",
description: "This is a new line",
is_giftcard: false,
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,
},
{
_id: IdMap.getId("giftline"),
title: "GiftCard",
description: "Gift card line",
thumbnail: "test-img-yeah.com/thumb",
metadata: {
name: "Test Name",
},
is_giftcard: true,
content: {
unit_price: 100,
variant: {
_id: IdMap.getId("giftCardVar"),
},
product: {
_id: IdMap.getId("giftCardProd"),
},
quantity: 1,
},
quantity: 1,
},
],
email: "test",
payment_sessions: [
{
provider_id: "default_provider",
data: {
money_id: "success",
},
},
],
payment_method: {
provider_id: "default_provider",
data: {
money_id: "success",
},
},
shipping_methods: [
{
provider_id: "gls",
data: {
yes: "sir",
},
},
],
shipping_address: {
first_name: "hi",
last_name: "you",
country_code: "DK",
city: "of lights",
address_1: "You bet street",
postal_code: "4242",
},
billing_address: {
first_name: "hi",
last_name: "you",
country_code: "DK",
city: "of lights",
address_1: "You bet street",
postal_code: "4242",
},
discounts: [],
customer_id: "",
},
completeCart: {
_id: IdMap.getId("complete-cart"),
region_id: IdMap.getId("region-france"),
@@ -122,7 +122,7 @@ export const discounts = {
}
export const DiscountModelMock = {
create: jest.fn().mockReturnValue(Promise.resolve()),
create: jest.fn().mockImplementation(data => Promise.resolve(data)),
updateOne: jest.fn().mockImplementation((query, update) => {
return Promise.resolve()
}),
@@ -109,6 +109,9 @@ export const orders = {
customer_id: IdMap.getId("test-customer"),
payment_method: {
provider_id: "default_provider",
data: {
hi: "hi",
},
},
shipping_methods: [
{
@@ -133,6 +136,7 @@ export const orders = {
orderToRefund: {
_id: IdMap.getId("refund-order"),
email: "oliver@test.dk",
tax_rate: 0.25,
billing_address: {
first_name: "Oli",
last_name: "Medusa",
@@ -11,7 +11,13 @@ export const profiles = {
_id: IdMap.getId("profile1"),
name: "Profile One",
products: [IdMap.getId("product1")],
shipping_options: [IdMap.getId("shipping1")],
shipping_options: [IdMap.getId("shipping_1")],
},
profile2: {
_id: IdMap.getId("profile2"),
name: "Profile two",
products: [IdMap.getId("product2")],
shipping_options: [IdMap.getId("shipping_2")],
},
}
@@ -21,6 +27,10 @@ export const ShippingProfileModelMock = {
return Promise.resolve()
}),
find: jest.fn().mockImplementation(query => {
if (query.products && query.products.$in) {
return Promise.resolve([profiles.profile1, profiles.profile2])
}
return Promise.resolve([])
}),
deleteOne: jest.fn().mockReturnValue(Promise.resolve()),
+16
View File
@@ -0,0 +1,16 @@
import mongoose from "mongoose"
import { BaseModel } from "medusa-interfaces"
class OauthModel extends BaseModel {
static modelName = "Oauth"
static schema = {
display_name: { type: String, required: true },
application_name: { type: String, required: true, unique: true },
install_url: { type: String, required: true },
uninstall_url: { type: String, default: "" },
data: { type: mongoose.Schema.Types.Mixed, default: {} },
}
}
export default OauthModel
+3
View File
@@ -6,6 +6,7 @@ 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"
class OrderModel extends BaseModel {
static modelName = "Order"
@@ -23,6 +24,8 @@ class OrderModel extends BaseModel {
shipping_address: { type: AddressSchema, required: true },
items: { type: [LineItemSchema], required: true },
currency_code: { type: String, required: true },
tax_rate: { type: Number, required: true },
shipments: { type: [ShipmentSchema], default: [] },
region_id: { type: String, required: true },
discounts: { type: [DiscountSchema], default: [] },
customer_id: { type: String },
+1
View File
@@ -7,6 +7,7 @@ class RegionModel extends BaseModel {
name: { type: String, required: true },
currency_code: { type: String, required: true },
tax_rate: { type: Number, required: true, default: 0 },
tax_code: { type: String },
countries: { type: [String], default: [] },
payment_providers: { type: [String], default: [] },
fulfillment_providers: { type: [String], default: [] },
@@ -3,6 +3,7 @@ import mongoose from "mongoose"
import DiscountRule from "./discount-rule"
export default new mongoose.Schema({
is_giftcard: { type: Boolean },
code: { type: String },
discount_rule: { type: DiscountRule },
usage_count: { type: Number },
@@ -8,6 +8,7 @@ export default new mongoose.Schema({
description: { type: String },
thumbnail: { type: String },
is_giftcard: { type: Boolean, default: false },
has_shipping: { type: Boolean, default: false },
// mongoose doesn't allow multi-type validation but this field allows both
// an object containing:
@@ -35,5 +36,6 @@ export default new mongoose.Schema({
content: { type: mongoose.Schema.Types.Mixed, required: true },
quantity: { type: Number, required: true },
returned: { type: Boolean, default: false },
returned_quantity: { type: Number, default: 0 },
metadata: { type: mongoose.Schema.Types.Mixed, default: {} },
})
@@ -0,0 +1,7 @@
import mongoose from "mongoose"
export default new mongoose.Schema({
item_ids: { type: [String], required: true },
tracking_number: { type: String, default: "" },
metadata: { type: mongoose.Schema.Types.Mixed, default: {} },
})
@@ -46,6 +46,11 @@ export const DiscountServiceMock = {
removeRegion: jest.fn().mockReturnValue(Promise.resolve()),
addValidVariant: jest.fn().mockReturnValue(Promise.resolve()),
removeValidVariant: jest.fn().mockReturnValue(Promise.resolve()),
generateGiftCard: jest.fn().mockReturnValue(
Promise.resolve({
_id: IdMap.getId("gift_card_id"),
})
),
}
const mock = jest.fn().mockImplementation(() => {
@@ -110,6 +110,7 @@ export const ShippingOptionServiceMock = {
_id: IdMap.getId("fail"),
})
}
return Promise.resolve({ _id: methodId })
}),
delete: jest.fn().mockReturnValue(Promise.resolve()),
}
@@ -29,7 +29,7 @@ export const ShippingProfileServiceMock = {
if (data === IdMap.getId("profile1")) {
return Promise.resolve(profiles.other)
}
return Promise.resolve()
return Promise.resolve(profiles.default)
}),
retrieveGiftCardDefault: jest.fn().mockImplementation(data => {
return Promise.resolve({ _id: IdMap.getId("giftCardProfile") })
+47 -5
View File
@@ -10,6 +10,7 @@ import { RegionServiceMock } from "../__mocks__/region"
import { EventBusServiceMock } from "../__mocks__/event-bus"
import { CustomerServiceMock } from "../__mocks__/customer"
import { ShippingOptionServiceMock } from "../__mocks__/shipping-option"
import { TotalsServiceMock } from "../__mocks__/totals"
import { ShippingProfileServiceMock } from "../__mocks__/shipping-profile"
import { CartModelMock, carts } from "../../models/__mocks__/cart"
import { LineItemServiceMock } from "../__mocks__/line-item"
@@ -193,7 +194,12 @@ describe("CartService", () => {
_id: IdMap.getId("emptyCart"),
},
{
$push: { items: lineItem },
$push: {
items: {
...lineItem,
has_shipping: false,
},
},
}
)
})
@@ -225,7 +231,10 @@ describe("CartService", () => {
"items._id": IdMap.getId("existingLine"),
},
{
$set: { "items.$.quantity": 20 },
$set: {
"items.$.quantity": 20,
"items.$.has_shipping": false,
},
}
)
})
@@ -267,7 +276,12 @@ describe("CartService", () => {
_id: IdMap.getId("cartWithLine"),
},
{
$push: { items: lineItem },
$push: {
items: {
...lineItem,
has_shipping: false,
},
},
}
)
})
@@ -390,10 +404,9 @@ describe("CartService", () => {
expect(CartModelMock.updateOne).toHaveBeenCalledWith(
{
_id: IdMap.getId("cartWithLine"),
"items._id": IdMap.getId("existingLine"),
},
{
$set: { "items.$.quantity": 9 },
$pull: { items: { _id: IdMap.getId("existingLine") } },
}
)
})
@@ -905,6 +918,7 @@ describe("CartService", () => {
cartModel: CartModelMock,
regionService: RegionServiceMock,
paymentProviderService: PaymentProviderServiceMock,
totalsService: TotalsServiceMock,
eventBusService: EventBusServiceMock,
})
@@ -1183,6 +1197,26 @@ describe("CartService", () => {
},
{
$set: {
items: [
{
_id: IdMap.getId("existingLine"),
title: "merge line",
description: "This is a new line",
has_shipping: true,
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,
},
],
shipping_methods: [
{
_id: IdMap.getId("freeShipping"),
@@ -1221,6 +1255,10 @@ describe("CartService", () => {
},
{
$set: {
items: carts.frCart.items.map(i => ({
...i,
has_shipping: false,
})),
shipping_methods: [
{
_id: IdMap.getId("freeShipping"),
@@ -1261,6 +1299,10 @@ describe("CartService", () => {
},
{
$set: {
items: carts.frCart.items.map(i => ({
...i,
has_shipping: false,
})),
shipping_methods: [
{
_id: IdMap.getId("freeShipping"),
@@ -6,6 +6,7 @@ import {
} from "../../models/__mocks__/dynamic-discount-code"
import { IdMap } from "medusa-test-utils"
import { ProductVariantServiceMock } from "../__mocks__/product-variant"
import { EventBusServiceMock } from "../__mocks__/event-bus"
import { RegionServiceMock } from "../__mocks__/region"
describe("DiscountService", () => {
@@ -274,4 +275,32 @@ describe("DiscountService", () => {
)
})
})
describe("generateGiftCard", () => {
const discountService = new DiscountService({
discountModel: DiscountModelMock,
regionService: RegionServiceMock,
eventBusService: EventBusServiceMock,
})
beforeEach(() => {
jest.clearAllMocks()
})
it("calls model layer create", async () => {
await discountService.generateGiftCard(100, IdMap.getId("testRegion"))
expect(DiscountModelMock.create).toHaveBeenCalledTimes(1)
expect(DiscountModelMock.create).toHaveBeenCalledWith({
code: expect.stringMatching(/(([A-Z0-9]){4}(-?)){4}/),
is_giftcard: true,
discount_rule: {
type: "fixed",
allocation: "total",
value: 100,
},
regions: [IdMap.getId("testRegion")],
})
})
})
})
@@ -22,7 +22,7 @@ describe("EventBusService", () => {
})
it("creates bull queue", () => {
expect(Bull).toHaveBeenCalledTimes(1)
expect(Bull).toHaveBeenCalledTimes(2)
expect(Bull).toHaveBeenCalledWith("EventBusService:queue", "testhost")
})
})
+180 -44
View File
@@ -2,7 +2,11 @@ import { IdMap } from "medusa-test-utils"
import { OrderModelMock, orders } from "../../models/__mocks__/order"
import { carts } from "../../models/__mocks__/cart"
import OrderService from "../order"
import { PaymentProviderServiceMock } from "../__mocks__/payment-provider"
import {
PaymentProviderServiceMock,
DefaultProviderMock,
} from "../__mocks__/payment-provider"
import { DiscountServiceMock } from "../__mocks__/discount"
import { FulfillmentProviderServiceMock } from "../__mocks__/fulfillment-provider"
import { ShippingProfileServiceMock } from "../__mocks__/shipping-profile"
import { TotalsServiceMock } from "../__mocks__/totals"
@@ -36,6 +40,7 @@ describe("OrderService", () => {
const orderService = new OrderService({
orderModel: OrderModelMock,
paymentProviderService: PaymentProviderServiceMock,
discountService: DiscountServiceMock,
regionService: RegionServiceMock,
eventBusService: EventBusServiceMock,
})
@@ -51,6 +56,7 @@ describe("OrderService", () => {
...carts.completeCart,
currency_code: "eur",
cart_id: carts.completeCart._id,
tax_rate: 0.25,
}
delete order._id
delete order.payment_sessions
@@ -60,6 +66,84 @@ describe("OrderService", () => {
session: expect.anything(),
})
})
it("creates cart with gift card", async () => {
await orderService.createFromCart(carts.withGiftCard)
const order = {
...carts.withGiftCard,
items: [
{
_id: IdMap.getId("existingLine"),
title: "merge line",
description: "This is a new line",
is_giftcard: false,
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,
},
{
_id: IdMap.getId("giftline"),
title: "GiftCard",
description: "Gift card line",
thumbnail: "test-img-yeah.com/thumb",
metadata: {
giftcard: IdMap.getId("gift_card_id"),
name: "Test Name",
},
is_giftcard: true,
content: {
unit_price: 100,
variant: {
_id: IdMap.getId("giftCardVar"),
},
product: {
_id: IdMap.getId("giftCardProd"),
},
quantity: 1,
},
quantity: 1,
},
],
currency_code: "eur",
cart_id: carts.withGiftCard._id,
tax_rate: 0.25,
}
delete order._id
delete order.payment_sessions
expect(EventBusServiceMock.emit).toHaveBeenCalledTimes(2)
expect(EventBusServiceMock.emit).toHaveBeenCalledWith(
"order.gift_card_created",
{
currency_code: "eur",
tax_rate: 0.25,
email: "test",
giftcard: expect.any(Object),
}
)
expect(DiscountServiceMock.generateGiftCard).toHaveBeenCalledTimes(1)
expect(DiscountServiceMock.generateGiftCard).toHaveBeenCalledWith(
100,
IdMap.getId("region-france")
)
expect(OrderModelMock.create).toHaveBeenCalledTimes(1)
expect(OrderModelMock.create).toHaveBeenCalledWith([order], {
session: expect.anything(),
})
})
})
describe("retrieve", () => {
@@ -280,6 +364,7 @@ describe("OrderService", () => {
const orderService = new OrderService({
orderModel: OrderModelMock,
paymentProviderService: PaymentProviderServiceMock,
eventBusService: EventBusServiceMock,
})
beforeEach(async () => {
@@ -297,11 +382,9 @@ describe("OrderService", () => {
})
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")
}
await expect(
orderService.capturePayment(IdMap.getId("payed-order"))
).rejects.toThrow("Payment already captured")
})
})
@@ -324,16 +407,36 @@ describe("OrderService", () => {
expect(OrderModelMock.updateOne).toHaveBeenCalledTimes(1)
expect(OrderModelMock.updateOne).toHaveBeenCalledWith(
{ _id: IdMap.getId("test-order") },
{ $set: { fulfillment_status: "fulfilled" } }
{
$set: {
fulfillment_status: "fulfilled",
shipping_methods: [
{
_id: IdMap.getId("expensiveShipping"),
items: [],
name: "Expensive Shipping",
price: 100,
profile_id: IdMap.getId("default"),
provider_id: "default_provider",
},
{
_id: IdMap.getId("freeShipping"),
items: [],
name: "Free Shipping",
price: 10,
profile_id: IdMap.getId("profile1"),
provider_id: "default_provider",
},
],
},
}
)
})
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")
}
await expect(
orderService.createFulfillment(IdMap.getId("fulfilled-order"))
).rejects.toThrow("Order is already fulfilled")
})
})
@@ -342,6 +445,7 @@ describe("OrderService", () => {
orderModel: OrderModelMock,
paymentProviderService: PaymentProviderServiceMock,
totalsService: TotalsServiceMock,
eventBusService: EventBusServiceMock,
})
beforeEach(async () => {
@@ -351,20 +455,7 @@ describe("OrderService", () => {
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,
},
item_id: IdMap.getId("existingLine"),
quantity: 10,
},
])
@@ -392,31 +483,75 @@ describe("OrderService", () => {
returned_quantity: 10,
thumbnail: "test-img-yeah.com/thumb",
title: "merge line",
returned: true,
},
],
fulfillment_status: "returned",
},
}
)
expect(DefaultProviderMock.refundPayment).toHaveBeenCalledTimes(1)
expect(DefaultProviderMock.refundPayment).toHaveBeenCalledWith(
{ hi: "hi" },
1230
)
})
it("calls order model functions and sets partially_fulfilled", async () => {
it("return with custom refund", async () => {
await orderService.return(
IdMap.getId("processed-order"),
[
{
item_id: IdMap.getId("existingLine"),
quantity: 10,
},
],
102
)
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",
returned: true,
},
],
fulfillment_status: "returned",
},
}
)
expect(DefaultProviderMock.refundPayment).toHaveBeenCalledTimes(1)
expect(DefaultProviderMock.refundPayment).toHaveBeenCalledWith(
{ hi: "hi" },
102
)
})
it("calls order model functions and sets partially_returned", 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,
},
item_id: IdMap.getId("existingLine"),
quantity: 2,
},
])
@@ -441,6 +576,7 @@ describe("OrderService", () => {
},
description: "This is a new line",
quantity: 10,
returned: false,
returned_quantity: 2,
thumbnail: "test-img-yeah.com/thumb",
title: "merge line",
@@ -463,7 +599,7 @@ describe("OrderService", () => {
quantity: 10,
},
],
fulfillment_status: "partially_fulfilled",
fulfillment_status: "partially_returned",
},
}
)
@@ -471,7 +607,7 @@ describe("OrderService", () => {
it("throws if payment is already processed", async () => {
try {
await orderService.return(IdMap.getId("fulfilled-order"))
await orderService.return(IdMap.getId("fulfilled-order"), [])
} catch (error) {
expect(error.message).toEqual(
"Can't return an order with payment unprocessed"
@@ -481,7 +617,7 @@ describe("OrderService", () => {
it("throws if return is attempted on unfulfilled order", async () => {
try {
await orderService.return(IdMap.getId("not-fulfilled-order"))
await orderService.return(IdMap.getId("not-fulfilled-order"), [])
} catch (error) {
expect(error.message).toEqual(
"Can't return an unfulfilled or already returned order"
@@ -353,6 +353,37 @@ describe("ShippingProfileService", () => {
})
})
describe("fetchCartOptions", () => {
const profileService = new ShippingProfileService({
shippingProfileModel: ShippingProfileModelMock,
shippingOptionService: ShippingOptionServiceMock,
})
beforeEach(() => {
jest.clearAllMocks()
})
it("fetches correct options", async () => {
await profileService.fetchCartOptions({
items: [
{
content: { product: { _id: IdMap.getId("product_1") } },
},
{
content: { product: { _id: IdMap.getId("product_2") } },
},
],
})
expect(ShippingProfileModelMock.find).toBeCalledTimes(1)
expect(ShippingProfileModelMock.find).toBeCalledWith({
products: { $in: [IdMap.getId("product_1"), IdMap.getId("product_2")] },
})
expect(ShippingOptionServiceMock.validateCartOption).toBeCalledTimes(2)
})
})
describe("addShippingOption", () => {
const profileService = new ShippingProfileService({
shippingProfileModel: ShippingProfileModelMock,
@@ -193,7 +193,7 @@ describe("TotalsService", () => {
description: "This is a new line",
thumbnail: "test-img-yeah.com/thumb",
content: {
unit_price: 123,
unit_price: 100,
variant: {
_id: IdMap.getId("can-cover"),
},
@@ -206,7 +206,7 @@ describe("TotalsService", () => {
},
])
expect(res).toEqual(1107)
expect(res).toEqual(1125)
})
it("calculates refund with total fixed discount", async () => {
@@ -218,7 +218,7 @@ describe("TotalsService", () => {
description: "This is a new line",
thumbnail: "test-img-yeah.com/thumb",
content: {
unit_price: 123,
unit_price: 100,
variant: {
_id: IdMap.getId("can-cover"),
},
@@ -231,7 +231,7 @@ describe("TotalsService", () => {
},
])
expect(res).toEqual(359)
expect(res).toEqual(373.125)
})
it("calculates refund with item fixed discount", async () => {
@@ -243,7 +243,7 @@ describe("TotalsService", () => {
description: "This is a new line",
thumbnail: "test-img-yeah.com/thumb",
content: {
unit_price: 123,
unit_price: 100,
variant: {
_id: IdMap.getId("eur-8-us-10"),
},
@@ -256,7 +256,7 @@ describe("TotalsService", () => {
},
])
expect(res).toEqual(363)
expect(res).toEqual(367.5)
})
it("calculates refund with item percentage discount", async () => {
@@ -268,7 +268,7 @@ describe("TotalsService", () => {
description: "This is a new line",
thumbnail: "test-img-yeah.com/thumb",
content: {
unit_price: 123,
unit_price: 100,
variant: {
_id: IdMap.getId("eur-8-us-10"),
},
@@ -281,7 +281,7 @@ describe("TotalsService", () => {
},
])
expect(res).toEqual(332.1)
expect(res).toEqual(337.5)
})
it("throws if line items to return is not in order", async () => {
+143 -50
View File
@@ -249,6 +249,23 @@ class CartService extends BaseService {
return c
}
/**
* Returns an array of product ids in a line item.
* @param {LineItem} item - the line item to fetch products from
* @return {[string]} an array of product ids
*/
getItemProducts_(item) {
// Find all the products in the line item
const products = []
if (Array.isArray(item.content)) {
item.content.forEach(c => products.push(`${c.product._id}`))
} else {
products.push(`${item.content.product._id}`)
}
return products
}
/**
* Removes a line item from the cart.
* @param {string} cartId - the id of the cart that we will remove from
@@ -258,33 +275,42 @@ class CartService extends BaseService {
async removeLineItem(cartId, lineItemId) {
const cart = await this.retrieve(cartId)
const itemToRemove = cart.items.find(line => line._id.equals(lineItemId))
if (!itemToRemove) {
return Promise.resolve(cart)
}
// If cart has more than one of those line items, we update the quantity
// instead of removing it
if (itemToRemove.quantity > 1) {
const newQuantity = itemToRemove.quantity - 1
const update = {
$pull: { items: { _id: itemToRemove._id } },
}
return this.cartModel_
.updateOne(
{
_id: cartId,
"items._id": itemToRemove._id,
},
{
$set: {
"items.$.quantity": newQuantity,
},
// Remove shipping methods if they are not needed
if (cart.shipping_methods && cart.shipping_methods.length) {
const filteredItems = cart.items.filter(i => !i._id.equals(lineItemId))
let newShippingMethods = await Promise.all(
cart.shipping_methods.map(async m => {
const profile = await this.shippingProfileService_.retrieve(
m.profile_id
)
const hasItem = filteredItems.find(item => {
const products = this.getItemProducts_(item)
return products.some(p => profile.products.includes(p))
})
if (hasItem) {
return m
}
)
.then(result => {
// Notify subscribers
this.eventBus_.emit(CartService.Events.UPDATED, result)
return result
return null
})
)
newShippingMethods = newShippingMethods.filter(n => !!n)
if (newShippingMethods.length !== cart.shipping_methods.length) {
update.$set = {
shipping_methods: newShippingMethods,
}
}
}
return this.cartModel_
@@ -292,11 +318,7 @@ class CartService extends BaseService {
{
_id: cartId,
},
{
$pull: {
items: { _id: itemToRemove._id },
},
}
update
)
.then(result => {
// Notify subscribers
@@ -305,6 +327,34 @@ class CartService extends BaseService {
})
}
/**
* Checks if a given line item has a shipping method that can fulfill it.
* Returns true if all products in the cart can be fulfilled with the current
* shipping methods.
* @param {Cart} cart - the cart
* @param {LineItem} lineItem - the line item
* @return {boolean}
*/
async validateLineItemShipping_(shippingMethods, lineItem) {
if (shippingMethods && shippingMethods.length) {
const profiles = await Promise.all(
shippingMethods.map(m =>
this.shippingProfileService_.retrieve(m.profile_id)
)
)
const products = this.getItemProducts_(lineItem)
// Check if there is a shipping method for each product
const hasShipping = products.map(
p => !!profiles.find(profile => profile.products.includes(p))
)
return hasShipping.every(b => b)
}
return false
}
/**
* Adds a line item to the cart.
* @param {string} cartId - the id of the cart that we will add to
@@ -318,6 +368,11 @@ class CartService extends BaseService {
this.lineItemService_.isEqual(line, validatedLineItem)
)
const hasShipping = await this.validateLineItemShipping_(
cart.shipping_methods,
validatedLineItem
)
// If content matches one of the line items currently in the cart we can
// simply update the quantity of the existing line item
if (currentItem) {
@@ -345,6 +400,7 @@ class CartService extends BaseService {
{
$set: {
"items.$.quantity": newQuantity,
"items.$.has_shipping": hasShipping,
},
}
)
@@ -375,7 +431,12 @@ class CartService extends BaseService {
_id: cartId,
},
{
$push: { items: validatedLineItem },
$push: {
items: {
...validatedLineItem,
has_shipping: hasShipping,
},
},
}
)
.then(result => {
@@ -808,6 +869,25 @@ class CartService extends BaseService {
const cart = await this.retrieve(cartId)
const region = await this.regionService_.retrieve(cart.region_id)
const total = await this.totalsService_.getTotal(cart)
if (total === 0) {
return this.cartModel_
.updateOne(
{
_id: cart._id,
},
{
$set: { payment_sessions: [] },
}
)
.then(result => {
// Notify subscribers
this.eventBus_.emit(CartService.Events.UPDATED, result)
return result
})
}
// If there are existing payment sessions ensure that these are up to date
let sessions = []
if (cart.payment_sessions && cart.payment_sessions.length) {
@@ -817,13 +897,22 @@ class CartService extends BaseService {
return null
}
// const data = await this.paymentProviderService_.updateSession(
// pSession,
// cart
// )
let data
try {
data = await this.paymentProviderService_.updateSession(
pSession,
cart
)
} catch (err) {
data = await this.paymentProviderService_.createSession(
pSession.provider_id,
cart
)
}
return {
provider_id: pSession.provider_id,
data: {},
data,
}
})
)
@@ -981,10 +1070,19 @@ class CartService extends BaseService {
newMethods.push(option)
}
const finalMethods = newMethods.map(m => {
const { _id, ...rest } = m
return rest
})
const newItems = await Promise.all(
cart.items.map(async item => {
const hasShipping = await this.validateLineItemShipping_(
newMethods,
item
)
return {
...item,
has_shipping: hasShipping,
}
})
)
return this.cartModel_
.updateOne(
@@ -992,7 +1090,7 @@ class CartService extends BaseService {
_id: cart._id,
},
{
$set: { shipping_methods: finalMethods },
$set: { shipping_methods: newMethods, items: newItems },
}
)
.then(result => {
@@ -1050,20 +1148,15 @@ class CartService extends BaseService {
update.shipping_methods = []
}
//if (cart.items.length && cart.payment_sessions.length) {
// update.payment_sessions = await Promise.all(
// region.payment_providers.map(async pId => {
// const data = await this.paymentProviderService_.createSession(pId, {
// ...cart,
// ...update,
// })
// return {
// provider_id: pId,
// data,
// }
// })
// )
//}
if (cart.discounts && cart.discounts.length) {
const newDiscounts = cart.discounts.map(d => {
if (d.regions.includes(regionId)) {
return d
}
})
update.discounts = newDiscounts.filter(d => !!d)
}
// Payment methods are region specific so the user needs to find a
// new payment method
+39 -2
View File
@@ -1,6 +1,7 @@
import _ from "lodash"
import randomize from "randomatic"
import { BaseService } from "medusa-interfaces"
import { Validator, MedusaError } from "medusa-core-utils"
import _ from "lodash"
/**
* Provides layer to manipulate discounts.
@@ -13,6 +14,7 @@ class DiscountService extends BaseService {
totalsService,
productVariantService,
regionService,
eventBusService,
}) {
super()
@@ -30,6 +32,9 @@ class DiscountService extends BaseService {
/** @private @const {RegionService} */
this.regionService_ = regionService
/** @private @const {EventBus} */
this.eventBus_ = eventBusService
}
/**
@@ -60,7 +65,7 @@ class DiscountService extends BaseService {
description: Validator.string(),
type: Validator.string().required(),
value: Validator.number()
.positive()
.min(0)
.required(),
allocation: Validator.string().required(),
valid_for: Validator.array().items(Validator.string()),
@@ -210,6 +215,38 @@ class DiscountService extends BaseService {
)
}
/**
* Generates a gift card with the specified value which is valid in the
* specified region.
* @param {number} value - the value that the gift card represents
* @param {string} regionId - the id of the region in which the gift card can
* be used
* @return {Discount} the newly created gift card
*/
async generateGiftCard(value, regionId) {
const region = await this.regionService_.retrieve(regionId)
const code = [
randomize("A0", 4),
randomize("A0", 4),
randomize("A0", 4),
randomize("A0", 4),
].join("-")
const discountRule = this.validateDiscountRule_({
type: "fixed",
allocation: "total",
value,
})
return this.discountModel_.create({
code,
discount_rule: discountRule,
is_giftcard: true,
regions: [region._id],
})
}
/**
* Creates a dynamic code for a discount id.
* @param {string} discountId - the id of the discount to create a code for
+55
View File
@@ -13,11 +13,20 @@ class EventBusService {
/** @private {object} */
this.observers_ = {}
/** @private {object} to handle cron jobs */
this.cronHandlers_ = {}
/** @private {BullQueue} used for cron jobs */
this.cronQueue_ = new Bull(`cron-jobs:queue`, config.redisURI)
/** @private {BullQueue} */
this.queue_ = new Bull(`${this.constructor.name}:queue`, config.redisURI)
// Register our worker to handle emit calls
this.queue_.process(this.worker_)
// Register cron worker
this.cronQueue_.process(this.cronWorker_)
}
/**
@@ -38,6 +47,21 @@ class EventBusService {
}
}
/**
*
*/
registerCronHandler_(event, subscriber) {
if (typeof subscriber !== "function") {
throw new Error("Handler must be a function")
}
if (this.observers_[event]) {
this.cronHandlers_[event].push(subscriber)
} else {
this.cronHandlers_[event] = [subscriber]
}
}
/**
* Calls all subscribers when an event occurs.
* @param {string} eventName - the name of the event to be process.
@@ -77,6 +101,37 @@ class EventBusService {
})
)
}
cronWorker_ = job => {
const { eventName, data } = job.data
const observers = this.cronHandlers_[eventName] || []
this.logger_.info(`Processing cron job: ${eventName}`)
return Promise.all(
observers.map(subscriber => {
return subscriber(data).catch(err => {
this.logger_.warn(
`An error occured while processing ${eventName}: ${err}`
)
return err
})
})
)
}
/**
* Registers a cron job.
*/
createCronJob(eventName, data, cron, handler) {
this.registerCronHandler(eventName, handler)
return this.cronQueue_.add(
{
eventName,
data,
},
{ repeat: { cron } }
)
}
}
export default EventBusService
@@ -37,6 +37,7 @@ class LineItemService extends BaseService {
const lineItemSchema = Validator.object({
title: Validator.string().required(),
is_giftcard: Validator.bool().optional(),
description: Validator.string()
.allow("")
.optional(),
+110
View File
@@ -0,0 +1,110 @@
import _ from "lodash"
import { Validator, MedusaError } from "medusa-core-utils"
import { OauthService } from "medusa-interfaces"
class Oauth extends OauthService {
static Events = {
TOKEN_GENERATED: "oauth.token_generated",
TOKEN_REFRESHED: "oauth.token_refreshed",
}
constructor(cradle) {
super()
this.container_ = cradle
this.model_ = cradle.oauthModel
this.eventBus_ = cradle.eventBusService
}
retrieveByName(appName) {
return this.model_.findOne({
application_name: appName,
})
}
list(selector) {
return this.model_.find(selector)
}
create(data) {
return this.model_.create({
display_name: data.display_name,
application_name: data.application_name,
install_url: data.install_url,
uninstall_url: data.uninstall_url,
})
}
update(id, update) {
return this.model_.updateOne(
{
_id: id,
},
update
)
}
async registerOauthApp(appDetails) {
const { application_name } = appDetails
const existing = await this.retrieveByName(application_name)
if (existing) {
return
}
return this.create(appDetails)
}
async generateToken(appName, code, state) {
const app = await this.retrieveByName(appName)
const service = this.container_[`${app.application_name}Oauth`]
if (!service) {
throw new MedusaError(
MedusaError.Types.INVALID_DATA,
`An OAuth handler for ${app.display_name} could not be found make sure the plugin is installed`
)
}
if (!app.state === state) {
throw new MedusaError(
MedusaError.Types.NOT_ALLOWED,
`${app.display_name} could not match state`
)
}
const authData = await service.generateToken(code)
return this.update(app._id, {
data: authData,
}).then(result => {
this.eventBus_.emit(
`${Oauth.Events.TOKEN_GENERATED}.${appName}`,
authData
)
return result
})
}
async refreshToken(appName, refreshToken) {
const app = await this.retrieveByName(appName)
const service = this.container_[`${app.application_name}Oauth`]
if (!service) {
throw new MedusaError(
MedusaError.Types.INVALID_DATA,
`An OAuth handler for ${app.display_name} could not be found make sure the plugin is installed`
)
}
const authData = await service.refreshToken(refreshToken)
return this.update(app._id, {
data: authData,
}).then(result => {
this.eventBus_.emit(
`${Oauth.Events.TOKEN_REFRESHED}.${appName}`,
authData
)
return result
})
}
}
export default Oauth
+188 -49
View File
@@ -4,6 +4,10 @@ import { BaseService } from "medusa-interfaces"
class OrderService extends BaseService {
static Events = {
GIFT_CARD_CREATED: "order.gift_card_created",
PAYMENT_CAPTURED: "order.payment_captured",
SHIPMENT_CREATED: "order.shipment_created",
ITEMS_RETURNED: "order.items_returned",
PLACED: "order.placed",
UPDATED: "order.updated",
CANCELLED: "order.cancelled",
@@ -14,6 +18,7 @@ class OrderService extends BaseService {
orderModel,
paymentProviderService,
shippingProfileService,
discountService,
fulfillmentProviderService,
lineItemService,
totalsService,
@@ -43,6 +48,9 @@ class OrderService extends BaseService {
/** @private @const {RegionService} */
this.regionService_ = regionService
/** @private @const {DiscountService} */
this.discountService_ = discountService
/** @private @const {EventBus} */
this.eventBus_ = eventBusService
}
@@ -233,28 +241,28 @@ class OrderService extends BaseService {
*/
async completeOrder(orderId) {
const order = await this.retrieve(orderId)
this.orderModel_
// Capture the payment
await this.capturePayment(orderId)
// Run all other registered events
const completeOrderJob = await this.eventBus_.emit(
OrderService.Events.COMPLETED,
result
)
await completeOrderJob.finished().catch(error => {
throw error
})
return this.orderModel_
.updateOne(
{ _id: order._id },
{
$set: { status: "completed" },
}
)
.then(async result => {
const completeOrderJob = await this.eventBus_.emit(
OrderService.Events.COMPLETED,
result
)
return completeOrderJob
.finished()
.then(async () => {
return this.retrieve(order._id)
})
.catch(error => {
throw error
})
})
.then(async result => {})
}
/**
@@ -328,6 +336,33 @@ class OrderService extends BaseService {
paymentSession.data
)
// Generate gift cards if in cart
const items = await Promise.all(
cart.items.map(async i => {
if (i.is_giftcard) {
const giftcard = await this.discountService_
.generateGiftCard(i.content.unit_price, region._id)
.then(result => {
this.eventBus_.emit(OrderService.Events.GIFT_CARD_CREATED, {
currency_code: region.currency_code,
tax_rate: region.tax_rate,
giftcard: result,
email: cart.email,
})
return result
})
return {
...i,
metadata: {
...i.metadata,
giftcard: giftcard._id,
},
}
}
return i
})
)
const o = {
payment_method: {
provider_id: paymentSession.provider_id,
@@ -335,13 +370,14 @@ class OrderService extends BaseService {
},
discounts: cart.discounts,
shipping_methods: cart.shipping_methods,
items: cart.items,
items,
shipping_address: cart.shipping_address,
billing_address: cart.shipping_address,
region_id: cart.region_id,
email: cart.email,
customer_id: cart.customer_id,
cart_id: cart._id,
tax_rate: region.tax_rate,
currency_code: region.currency_code,
metadata: cart.metadata,
}
@@ -352,11 +388,63 @@ class OrderService extends BaseService {
// Emit and return
this.eventBus_.emit(OrderService.Events.PLACED, orderDocument[0])
return orderDocument[0].toObject()
return orderDocument[0]
})
.then(() => this.orderModel_.findOne({ cart_id: cart._id }))
}
/**
* Adds a shipment to the order to indicate that an order has left the warehouse
*/
async createShipment(orderId, shipment) {
const order = await this.retrieve(orderId)
console.log(order)
if (order.fulfillment_status === "shipped") {
throw new MedusaError(
MedusaError.Types.NOT_ALLOWED,
"Order has already been shipped"
)
}
const shipmentSchema = Validator.object({
item_ids: Validator.array()
.items(Validator.string())
.required(),
tracking_number: Validator.string().required(),
})
const { value, error } = shipmentSchema.validate(shipment)
if (error) {
throw new MedusaError(
MedusaError.Types.INVALID_DATA,
`Shipment not valid: ${error}`
)
}
const existing = order.shipments || []
const shipments = [...existing, value]
const allCovered = order.items.every(
i => !!shipments.find(s => s.item_ids.includes(`${i._id}`))
)
const update = {
$push: { shipments: value },
$set: {
fulfillment_status: allCovered ? "shipped" : "partially_shipped",
},
}
// Add the shipment to the order
return this.orderModel_.updateOne({ _id: orderId }, update).then(result => {
this.eventBus_.emit(OrderService.Events.SHIPMENT_CREATED, {
order_id: orderId,
shipment,
})
return result
})
}
/**
* Creates an order
* @param {object} order - the order to create
@@ -538,14 +626,19 @@ class OrderService extends BaseService {
)
}
return this.orderModel_.updateOne(
{
_id: orderId,
},
{
$set: updateFields,
}
)
return this.orderModel_
.updateOne(
{
_id: orderId,
},
{
$set: updateFields,
}
)
.then(result => {
this.eventBus_.emit(OrderService.Events.PAYMENT_CAPTURED, result)
return result
})
}
/**
@@ -576,14 +669,17 @@ class OrderService extends BaseService {
}
// partition order items to their dedicated shipping method
order.shipping_methods = await this.partitionItems_(shipping_methods, items)
updateFields.shipping_methods = await this.partitionItems_(
shipping_methods,
items
)
await Promise.all(
order.shipping_methods.map(method => {
updateFields.shipping_methods.map(method => {
const provider = this.fulfillmentProviderService_.retrieveProvider(
method.provider_id
)
provider.createOrder(method.data, method.items)
return provider.createOrder(method.data, method.items)
})
)
@@ -612,9 +708,25 @@ class OrderService extends BaseService {
* @param {string[]} lineItems - the line items to return
* @return {Promise} the result of the update operation
*/
async return(orderId, lineItems) {
async return(orderId, lineItems, refundAmount) {
const order = await this.retrieve(orderId)
// Find the lines to return
const returnLines = lineItems.map(({ item_id, quantity }) => {
const item = order.items.find(i => i._id.equals(item_id))
if (!item) {
throw new MedusaError(
MedusaError.Types.INVALID_DATA,
"Return contains invalid line item"
)
}
return {
...item,
quantity,
}
})
if (
order.fulfillment_status === "not_fulfilled" ||
order.fulfillment_status === "returned"
@@ -637,31 +749,50 @@ class OrderService extends BaseService {
provider_id
)
const amount = this.totalsService_.getRefundTotal(order, lineItems)
const amount =
refundAmount || this.totalsService_.getRefundTotal(order, returnLines)
await paymentProvider.refundPayment(data, amount)
lineItems.map(item => {
const returnedItem = order.items.find(({ _id }) => _id === item._id)
if (returnedItem) {
returnedItem.returned_quantity = item.quantity
let isFullReturn = true
const newItems = order.items.map(i => {
const isReturn = returnLines.find(r => r._id.equals(i._id))
if (isReturn) {
let returned = false
if (i.quantity === isReturn.quantity) {
returned = true
}
return {
...i,
returned_quantity: isReturn.quantity,
returned,
}
} else {
isFullReturn = false
return i
}
})
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",
return this.orderModel_
.updateOne(
{
_id: orderId,
},
}
)
{
$set: {
items: newItems,
fulfillment_status: isFullReturn
? "returned"
: "partially_returned",
},
}
)
.then(result => {
this.eventBus_.emit(OrderService.Events.ITEMS_RETURNED, {
order: result,
items: returnLines,
})
return result
})
}
/**
@@ -708,6 +839,14 @@ class OrderService extends BaseService {
if (expandFields.includes("region")) {
o.region = await this.regionService_.retrieve(order.region_id)
}
o.items = o.items.map(i => {
return {
...i,
refundable: this.totalsService_.getLineItemRefund(o, i),
}
})
return o
}
+8 -1
View File
@@ -143,7 +143,14 @@ class ProductService extends BaseService {
}
if (update.variants) {
update.variants = await Promise.all(
const existingVariants = await this.retrieveVariants(validatedId)
for (const existing of existingVariants) {
if (!update.variants.find(v => v._id && existing._id.equals(v._id))) {
await this.deleteVariant(productId, existing._id)
}
}
await Promise.all(
update.variants.map(async variant => {
if (variant._id) {
if (variant.prices && variant.prices.length) {
@@ -101,7 +101,7 @@ class ShippingProfileService extends BaseService {
* Retrieves the default gift card profile
* @return the shipping profile for gift cards
*/
async retrieveGiftCardProfile() {
async retrieveGiftCardDefault() {
return await this.profileModel_
.findOne({ name: "default_gift_card_profile" })
.catch(err => {
@@ -115,7 +115,7 @@ class ShippingProfileService extends BaseService {
* @return {Promise<ShippingProfile>} the shipping profile
*/
async createGiftCardDefault() {
const profile = await this.retrieveGiftCardProfile()
const profile = await this.retrieveGiftCardDefault()
if (!profile) {
return this.profileModel_.create({ name: "default_gift_card_profile" })
}
@@ -388,13 +388,21 @@ class ShippingProfileService extends BaseService {
)
const options = await Promise.all(
optionIds.map(oId => {
return this.shippingOptionService_
optionIds.map(async oId => {
const option = await this.shippingOptionService_
.validateCartOption(oId, cart)
.catch(err => {
// If validation failed we skip the option
return null
})
if (option) {
return {
...option,
profile: profiles.find(p => p._id.equals(option.profile_id)),
}
}
return null
})
)
+78 -112
View File
@@ -80,6 +80,32 @@ class TotalsService extends BaseService {
return (subtotal - discountTotal + shippingTotal) * tax_rate
}
getLineItemRefund(order, lineItem) {
const { tax_rate, discounts } = order
const taxRate = tax_rate || 0
const discount = discounts.find(
({ discount_rule }) => discount_rule.type !== "free_shipping"
)
if (!discount) {
return lineItem.content.unit_price * lineItem.quantity * (1 + taxRate)
}
const lineDiscounts = this.getLineDiscounts(order, discount)
const discountedLine = lineDiscounts.find(line =>
line.item._id.equals(lineItem._id)
)
const discountAmount =
(discountedLine.amount / discountedLine.item.quantity) * lineItem.quantity
return (
(lineItem.content.unit_price * lineItem.quantity - discountAmount) *
(1 + taxRate)
)
}
/**
* Calculates refund total of line items.
* If any of the items to return have been discounted, we need to
@@ -88,101 +114,9 @@ class TotalsService extends BaseService {
* @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_id)
// 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
}
getRefundTotal(order, lineItems) {
const refunds = lineItems.map(i => this.getLineItemRefund(order, i))
return refunds.reduce((acc, next) => acc + next, 0)
}
/**
@@ -225,7 +159,7 @@ class TotalsService extends BaseService {
* @return {[{ string, string, int }]} array of triples of lineitem, variant
* and applied discount
*/
async getAllocationItemDiscounts(discount, cart) {
getAllocationItemDiscounts(discount, cart) {
const discounts = []
for (const item of cart.items) {
if (discount.discount_rule.valid_for.length > 0) {
@@ -252,6 +186,45 @@ class TotalsService extends BaseService {
return discounts
}
getLineDiscounts(cart, discount) {
const subtotal = this.getSubtotal(cart)
const { type, allocation, value } = discount.discount_rule
if (allocation === "total") {
let percentage = 0
if (type === "percentage") {
percentage = value / 100
} else if (type === "fixed") {
percentage = value / subtotal
}
return cart.items.map(item => {
const lineTotal = item.content.unit_price * item.quantity
return {
item,
amount: lineTotal * percentage,
}
})
} else if (allocation === "item") {
const allocationDiscounts = this.getAllocationItemDiscounts(
discount,
cart,
type
)
return cart.items.map(item => {
const discounted = allocationDiscounts.find(a =>
a.lineItem._id.equals(item._id)
)
return {
item,
amount: !!discounted ? discounted.amount : 0,
}
})
}
return cart.items.map(i => ({ item: i, amount: 0 }))
}
/**
* Calculates the total discount amount for each of the different supported
* discount types. If discounts aren't present or invalid returns 0.
@@ -292,36 +265,29 @@ class TotalsService extends BaseService {
}
const { type, allocation, value } = discount.discount_rule
let toReturn = 0
if (type === "percentage" && allocation === "total") {
return (subtotal / 100) * value
}
if (type === "percentage" && allocation === "item") {
toReturn = (subtotal / 100) * value
} else if (type === "percentage" && allocation === "item") {
const itemPercentageDiscounts = await this.getAllocationItemDiscounts(
discount,
cart,
"percentage"
)
const totalDiscount = _.sumBy(itemPercentageDiscounts, d => d.amount)
return totalDiscount
}
if (type === "fixed" && allocation === "total") {
return value
}
if (type === "fixed" && allocation === "item") {
toReturn = _.sumBy(itemPercentageDiscounts, d => d.amount)
} else if (type === "fixed" && allocation === "total") {
toReturn = value
} else if (type === "fixed" && allocation === "item") {
const itemFixedDiscounts = await this.getAllocationItemDiscounts(
discount,
cart,
"fixed"
)
const totalDiscount = _.sumBy(itemFixedDiscounts, d => d.amount)
return totalDiscount
toReturn = _.sumBy(itemFixedDiscounts, d => d.amount)
}
return 0
return Math.min(subtotal, toReturn)
}
}
+44 -8
View File
@@ -4,21 +4,21 @@ class OrderSubscriber {
cartService,
customerService,
eventBusService,
discountService,
totalsService,
}) {
this.totalsService_ = totalsService
this.paymentProviderService_ = paymentProviderService
this.customerService_ = customerService
this.discountService_ = discountService
this.cartService_ = cartService
this.eventBus_ = eventBusService
this.eventBus_.subscribe("order.completed", async order => {
const paymentProvider = this.paymentProviderService_.retrieveProvider(
order.payment_method.provider_id
)
await paymentProvider.capturePayment(order._id)
})
this.eventBus_.subscribe("order.placed", async order => {
await this.customerService_.addOrder(order.customer_id, order._id)
@@ -29,6 +29,42 @@ class OrderSubscriber {
await this.customerService_.addAddress(order.customer_id, address)
})
this.eventBus_.subscribe("order.placed", async order => {
await this.cartService_.delete(order.cart_id).catch(err => {
if (err.type !== "not_found") {
throw err
}
})
})
this.eventBus_.subscribe("order.placed", this.handleDiscounts)
}
handleDiscounts = async order => {
await Promise.all(
order.discounts.map(async d => {
const subtotal = await this.totalsService_.getSubtotal(order)
if (d.is_giftcard) {
const discountRule = {
...d.discount_rule,
value: Math.max(0, d.discount_rule.value - subtotal),
}
delete discountRule._id
return this.discountService_.update(d._id, {
discount_rule: discountRule,
usage_count: d.usage_count + 1,
disabled: discountRule.value === 0,
})
} else {
return this.discountService_.update(d._id, {
usage_count: d.usage_count + 1,
})
}
})
)
}
}
+19 -15
View File
@@ -3665,6 +3665,11 @@ is-number@^3.0.0:
dependencies:
kind-of "^3.0.2"
is-number@^4.0.0:
version "4.0.0"
resolved "https://registry.yarnpkg.com/is-number/-/is-number-4.0.0.tgz#0026e37f5454d73e356dfe6564699867c6a7f0ff"
integrity sha512-rSklcAIlf1OmFdyAqbnWTLVelsQ58uvZ66S/ZyawjWqIviTWCjg2PzVGw8WUA+nNuPTqb4wgA+NszrJ+08LlgQ==
is-number@^7.0.0:
version "7.0.0"
resolved "https://registry.yarnpkg.com/is-number/-/is-number-7.0.0.tgz#7535345b896734d5f80c4d06c50955527a14f12b"
@@ -4553,19 +4558,16 @@ map-visit@^1.0.0:
dependencies:
object-visit "^1.0.0"
math-random@^1.0.1:
version "1.0.4"
resolved "https://registry.yarnpkg.com/math-random/-/math-random-1.0.4.tgz#5dd6943c938548267016d4e34f057583080c514c"
integrity sha512-rUxjysqif/BZQH2yhd5Aaq7vXMSx9NdEsQcyA07uEzIvxgI7zIr33gGsh+RU0/XjmQpCW7RsVof1vlkvQVCK5A==
media-typer@0.3.0:
version "0.3.0"
resolved "https://registry.yarnpkg.com/media-typer/-/media-typer-0.3.0.tgz#8710d7af0aa626f8fffa1ce00168545263255748"
integrity sha1-hxDXrwqmJvj/+hzgAWhUUmMlV0g=
medusa-core-utils@^0.3.0:
version "0.1.39"
resolved "https://registry.yarnpkg.com/medusa-core-utils/-/medusa-core-utils-0.1.39.tgz#d57816c9bd43f9a92883650c1e66add1665291df"
integrity sha512-R8+U1ile7if+nR6Cjh5exunx0ETV0OfkWUUBUpz1KmHSDv0V0CcvQqU9lcZesPFDEbu3Y2iEjsCqidVA4nG2nQ==
dependencies:
"@hapi/joi" "^16.1.8"
joi-objectid "^3.0.1"
medusa-interfaces@^0.1.27:
version "0.1.27"
resolved "https://registry.yarnpkg.com/medusa-interfaces/-/medusa-interfaces-0.1.27.tgz#e77f9a9f82a7118eac8b35c1498ef8a5cec78898"
@@ -4573,13 +4575,6 @@ medusa-interfaces@^0.1.27:
dependencies:
mongoose "^5.8.0"
medusa-test-utils@^0.3.0:
version "0.1.39"
resolved "https://registry.yarnpkg.com/medusa-test-utils/-/medusa-test-utils-0.1.39.tgz#b7c166006a2fa4f02e52ab3bfafc19a3ae787f3e"
integrity sha512-M/Br8/HYvl7x2oLnme4NxdQwoyV0XUyOWiCyvPp7q1HUTB684lhJf1MikZVrcSjsh2L1rpyi3GRbKdf4cpJWvw==
dependencies:
mongoose "^5.8.0"
memory-pager@^1.0.2:
version "1.5.0"
resolved "https://registry.yarnpkg.com/memory-pager/-/memory-pager-1.5.0.tgz#d8751655d22d384682741c972f2c3d6dfa3e66b5"
@@ -5501,6 +5496,15 @@ random-bytes@~1.0.0:
resolved "https://registry.yarnpkg.com/random-bytes/-/random-bytes-1.0.0.tgz#4f68a1dc0ae58bd3fb95848c30324db75d64360b"
integrity sha1-T2ih3Arli9P7lYSMMDJNt11kNgs=
randomatic@^3.1.1:
version "3.1.1"
resolved "https://registry.yarnpkg.com/randomatic/-/randomatic-3.1.1.tgz#b776efc59375984e36c537b2f51a1f0aff0da1ed"
integrity sha512-TuDE5KxZ0J461RVjrJZCJc+J+zCkTb1MbH9AQUq68sMhOMcy9jLcb3BrZKgp9q9Ncltdg4QVqWrH02W2EFFVYw==
dependencies:
is-number "^4.0.0"
kind-of "^6.0.0"
math-random "^1.0.1"
range-parser@~1.2.1:
version "1.2.1"
resolved "https://registry.yarnpkg.com/range-parser/-/range-parser-1.2.1.tgz#3cf37023d199e1c24d1a55b84800c2f3e6468031"