diff --git a/packages/medusa-plugin-brightpearl/README.md b/packages/medusa-plugin-brightpearl/README.md index b0dc9384ab..3a9a3e703a 100644 --- a/packages/medusa-plugin-brightpearl/README.md +++ b/packages/medusa-plugin-brightpearl/README.md @@ -13,8 +13,9 @@ Sends orders to Brightpearl, listens for stock movements, handles returns. default_status_id: [the status id to assign new orders with] (optional: defaults to 1) payment_method_code: [the method code to register payments with] (optional: defaults to 1220) sales_account_code: [nominal code to assign line items to] (optional: defaults to 4000) - shipping_account_code: [nominal code to assign shipping line to] (optional: defaults to 4040)jk + shipping_account_code: [nominal code to assign shipping line to] (optional: defaults to 4040) discount_account_code: [nominal code to use for Discount-type refunds] (optional) + gift_card_account_code: [nominal code to use for gift card products and redeems] (optional: default to 4000) inventory_sync_cron: [cron pattern for inventory sync, if left out the job will not be created] (default: false) ``` diff --git a/packages/medusa-plugin-brightpearl/src/services/brightpearl.js b/packages/medusa-plugin-brightpearl/src/services/brightpearl.js index 66cced86d6..86671f30b9 100644 --- a/packages/medusa-plugin-brightpearl/src/services/brightpearl.js +++ b/packages/medusa-plugin-brightpearl/src/services/brightpearl.js @@ -501,7 +501,7 @@ class BrightpearlService extends BaseService { ({ discount_rule }) => discount_rule.type !== "free_shipping" ) let lineDiscounts = [] - if (discount) { + if (discount && !discount.is_giftcard) { lineDiscounts = this.totalsService_.getLineDiscounts(fromOrder, discount) } @@ -511,7 +511,7 @@ class BrightpearlService extends BaseService { item.content.variant.sku ) - const discount = lineDiscounts.find((l) => item._id === l.item._id) || { + const ld = lineDiscounts.find((l) => item._id === l.item._id) || { amount: 0, } @@ -522,7 +522,7 @@ class BrightpearlService extends BaseService { row.name = item.title } row.net = this.totalsService_.rounded( - item.content.unit_price * item.quantity - discount.amount + item.content.unit_price * item.quantity - ld.amount ) row.tax = this.totalsService_.rounded(row.net * fromOrder.tax_rate) row.quantity = item.quantity @@ -530,10 +530,34 @@ class BrightpearlService extends BaseService { row.externalRef = item._id row.nominalCode = this.options.sales_account_code || "4000" + if (item.is_giftcard) { + row.nominalCode = this.options.gift_card_account_code || "4000" + } + return row }) ) + // If a gift card was applied to the order we reduce the order amount + // correspondingly. This reduces the amount payable, while debiting the + // gift card account that was previously credited, when the gift card was + // purchased. + if (discount && discount.is_giftcard) { + const discountTotal = await this.totalsService_.getDiscountTotal( + fromOrder + ) + lines.push({ + name: `Gift Card: ${discount.code}`, + net: -1 * discountTotal, + tax: this.totalsService_.rounded( + -1 * discountTotal * fromOrder.tax_rate + ), + quantity: 1, + taxCode: region.tax_code, + nominalCode: this.options.gift_card_account_code || "4000", + }) + } + const shippingTotal = this.totalsService_.getShippingTotal(fromOrder) const shippingMethods = fromOrder.shipping_methods if (shippingMethods.length > 0) { diff --git a/packages/medusa/src/api/routes/admin/discounts/__tests__/list-discounts.js b/packages/medusa/src/api/routes/admin/discounts/__tests__/list-discounts.js index 5c4e0ccf15..a5342463fb 100644 --- a/packages/medusa/src/api/routes/admin/discounts/__tests__/list-discounts.js +++ b/packages/medusa/src/api/routes/admin/discounts/__tests__/list-discounts.js @@ -7,6 +7,7 @@ describe("GET /admin/discounts", () => { let subject beforeAll(async () => { + jest.clearAllMocks() subject = await request("GET", `/admin/discounts`, { adminSession: { jwt: { @@ -22,7 +23,61 @@ describe("GET /admin/discounts", () => { it("calls service retrieve", () => { expect(DiscountServiceMock.list).toHaveBeenCalledTimes(1) - expect(DiscountServiceMock.list).toHaveBeenCalledWith() + expect(DiscountServiceMock.list).toHaveBeenCalledWith({}) + }) + }) + + describe("is_giftcard filter", () => { + let subject + + beforeAll(async () => { + jest.clearAllMocks() + subject = await request("GET", `/admin/discounts?is_giftcard=true`, { + adminSession: { + jwt: { + userId: IdMap.getId("admin_user"), + }, + }, + }) + }) + + it("returns 200", () => { + expect(subject.status).toEqual(200) + }) + + it("calls service retrieve", () => { + expect(DiscountServiceMock.list).toHaveBeenCalledTimes(1) + expect(DiscountServiceMock.list).toHaveBeenCalledWith({ + is_giftcard: true, + }) + }) + }) + + describe("expand region filter", () => { + let subject + + beforeAll(async () => { + jest.clearAllMocks() + subject = await request("GET", `/admin/discounts?expand_fields=regions`, { + adminSession: { + jwt: { + userId: IdMap.getId("admin_user"), + }, + }, + }) + }) + + it("returns 200", () => { + expect(subject.status).toEqual(200) + }) + + it("calls service retrieve", () => { + expect(DiscountServiceMock.decorate).toHaveBeenCalledTimes(1) + expect( + DiscountServiceMock.decorate + ).toHaveBeenCalledWith(expect.anything(), expect.anything(), ["regions"]) + expect(DiscountServiceMock.list).toHaveBeenCalledTimes(1) + expect(DiscountServiceMock.list).toHaveBeenCalledWith({}) }) }) }) diff --git a/packages/medusa/src/api/routes/admin/discounts/list-discounts.js b/packages/medusa/src/api/routes/admin/discounts/list-discounts.js index 0ec0587dc4..9ed735f8d6 100644 --- a/packages/medusa/src/api/routes/admin/discounts/list-discounts.js +++ b/packages/medusa/src/api/routes/admin/discounts/list-discounts.js @@ -1,7 +1,34 @@ export default async (req, res) => { try { + const selector = {} + const discountService = req.scope.resolve("discountService") - const data = await discountService.list() + + if ("is_giftcard" in req.query) { + selector.is_giftcard = req.query.is_giftcard === "true" + } + + let expandFields = [] + if ("expand_fields" in req.query) { + expandFields = req.query.expand_fields.split(",") + } + + let includeFields = [ + "usage_count", + "starts_at", + "ends_at", + "original_amount", + "created", + ] + if ("fields" in req.query) { + includeFields = req.query.fields.split(",") + } + + const raw = await discountService.list(selector) + + const data = await Promise.all( + raw.map(d => discountService.decorate(d, includeFields, expandFields)) + ) res.status(200).json({ discounts: data }) } catch (err) { diff --git a/packages/medusa/src/api/routes/admin/products/list-products.js b/packages/medusa/src/api/routes/admin/products/list-products.js index 0355ad96c4..a31c2582d1 100644 --- a/packages/medusa/src/api/routes/admin/products/list-products.js +++ b/packages/medusa/src/api/routes/admin/products/list-products.js @@ -10,6 +10,10 @@ export default async (req, res) => { "description", ]) + if ("is_giftcard" in req.query) { + query.is_giftcard = req.query.is_giftcard === "true" + } + const limit = parseInt(req.query.limit) || 0 const offset = parseInt(req.query.offset) || 0 @@ -40,7 +44,6 @@ export default async (req, res) => { res.json({ products, total_count: numProducts }) } catch (error) { - console.log(error) throw error } } diff --git a/packages/medusa/src/models/discount.js b/packages/medusa/src/models/discount.js index 217a2a222c..b67f2c7571 100644 --- a/packages/medusa/src/models/discount.js +++ b/packages/medusa/src/models/discount.js @@ -15,6 +15,8 @@ class DiscountModel extends BaseModel { starts_at: { type: Date }, ends_at: { type: Date }, regions: { type: [String], default: [] }, + original_amount: { type: Number }, + created: { type: String, default: Date.now }, metadata: { type: mongoose.Schema.Types.Mixed, default: {} }, } } diff --git a/packages/medusa/src/models/order.js b/packages/medusa/src/models/order.js index d0ae816bcc..e1b47457a3 100644 --- a/packages/medusa/src/models/order.js +++ b/packages/medusa/src/models/order.js @@ -36,8 +36,8 @@ class OrderModel extends BaseModel { region_id: { type: String, required: true }, discounts: { type: [DiscountSchema], default: [] }, customer_id: { type: String }, - payment_method: { type: PaymentMethodSchema, required: true }, - shipping_methods: { type: [ShippingMethodSchema], required: true }, + payment_method: { type: PaymentMethodSchema, default: {} }, + shipping_methods: { type: [ShippingMethodSchema], default: [] }, documents: { type: [String], default: [] }, created: { type: String, default: Date.now }, metadata: { type: mongoose.Schema.Types.Mixed, default: {} }, diff --git a/packages/medusa/src/models/schemas/line-item.js b/packages/medusa/src/models/schemas/line-item.js index 5a9c3df868..3964a56424 100644 --- a/packages/medusa/src/models/schemas/line-item.js +++ b/packages/medusa/src/models/schemas/line-item.js @@ -3,6 +3,10 @@ ******************************************************************************/ import mongoose from "mongoose" +/** + * REMEMBER: When updating this line you must also update the LineItemService's + * validate method too. Otherwise we cannot copy lines directly. + */ export default new mongoose.Schema( { title: { type: String, required: true }, diff --git a/packages/medusa/src/models/schemas/payment-method.js b/packages/medusa/src/models/schemas/payment-method.js index 92963b831b..642cc5d76b 100644 --- a/packages/medusa/src/models/schemas/payment-method.js +++ b/packages/medusa/src/models/schemas/payment-method.js @@ -4,6 +4,6 @@ import mongoose from "mongoose" export default new mongoose.Schema({ - provider_id: { type: String, required: true }, + provider_id: { type: String }, data: { type: mongoose.Schema.Types.Mixed, default: {} }, }) diff --git a/packages/medusa/src/services/__mocks__/discount.js b/packages/medusa/src/services/__mocks__/discount.js index 0c370f0050..63086b6a66 100644 --- a/packages/medusa/src/services/__mocks__/discount.js +++ b/packages/medusa/src/services/__mocks__/discount.js @@ -40,7 +40,7 @@ export const DiscountServiceMock = { }) }), list: jest.fn().mockImplementation(data => { - return Promise.resolve([]) + return Promise.resolve([{}]) }), decorate: jest.fn().mockImplementation(data => { return Promise.resolve(data) diff --git a/packages/medusa/src/services/__tests__/discount.js b/packages/medusa/src/services/__tests__/discount.js index 1de10e3cf0..9e30a7ae4d 100644 --- a/packages/medusa/src/services/__tests__/discount.js +++ b/packages/medusa/src/services/__tests__/discount.js @@ -294,6 +294,7 @@ describe("DiscountService", () => { expect(DiscountModelMock.create).toHaveBeenCalledWith({ code: expect.stringMatching(/(([A-Z0-9]){4}(-?)){4}/), is_giftcard: true, + original_amount: 100, discount_rule: { type: "fixed", allocation: "total", diff --git a/packages/medusa/src/services/__tests__/order.js b/packages/medusa/src/services/__tests__/order.js index e2be298282..e0620616b5 100644 --- a/packages/medusa/src/services/__tests__/order.js +++ b/packages/medusa/src/services/__tests__/order.js @@ -45,6 +45,7 @@ describe("OrderService", () => { const orderService = new OrderService({ orderModel: OrderModelMock, paymentProviderService: PaymentProviderServiceMock, + totalsService: TotalsServiceMock, discountService: DiscountServiceMock, regionService: RegionServiceMock, eventBusService: EventBusServiceMock, @@ -56,7 +57,10 @@ describe("OrderService", () => { }) it("calls order model functions", async () => { - await orderService.createFromCart(carts.completeCart) + await orderService.createFromCart({ + ...carts.completeCart, + total: 100, + }) const order = { ...carts.completeCart, @@ -74,8 +78,34 @@ describe("OrderService", () => { }) }) + it("creates cart with 0 total", async () => { + await orderService.createFromCart({ + ...carts.completeCart, + total: 0, + }) + + const order = { + ...carts.completeCart, + payment_method: {}, + currency_code: "eur", + cart_id: carts.completeCart._id, + tax_rate: 0.25, + metadata: {}, + } + delete order._id + delete order.payment_sessions + + expect(OrderModelMock.create).toHaveBeenCalledTimes(1) + expect(OrderModelMock.create).toHaveBeenCalledWith([order], { + session: expect.anything(), + }) + }) + it("creates cart with gift card", async () => { - await orderService.createFromCart(carts.withGiftCard) + await orderService.createFromCart({ + ...carts.withGiftCard, + total: 100, + }) const order = { ...carts.withGiftCard, @@ -105,7 +135,6 @@ describe("OrderService", () => { description: "Gift card line", thumbnail: "test-img-yeah.com/thumb", metadata: { - giftcard: IdMap.getId("gift_card_id"), name: "Test Name", }, is_giftcard: true, @@ -130,24 +159,6 @@ describe("OrderService", () => { 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), - line_item: 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(), diff --git a/packages/medusa/src/services/discount.js b/packages/medusa/src/services/discount.js index 8da05c2f36..7c0d266b71 100644 --- a/packages/medusa/src/services/discount.js +++ b/packages/medusa/src/services/discount.js @@ -248,6 +248,7 @@ class DiscountService extends BaseService { discount_rule: discountRule, is_giftcard: true, regions: [region._id], + original_amount: value, }) } @@ -444,13 +445,42 @@ class DiscountService extends BaseService { * @return {Discount} return the decorated discount. */ async decorate(discount, fields = [], expandFields = []) { - const requiredFields = ["_id", "metadata"] + const requiredFields = [ + "_id", + "code", + "regions", + "discount_rule", + "is_dynamic", + "is_giftcard", + "disabled", + "metadata", + ] const decorated = _.pick(discount, fields.concat(requiredFields)) if (expandFields.includes("valid_for")) { + let prods = {} decorated.discount_rule.valid_for = await Promise.all( decorated.discount_rule.valid_for.map(async p => { - return this.productService_.retrieve(p) + if (p in prods) { + return prods[p] + } + const next = await this.productService_.retrieve(p) + prods[p] = next + return next + }) + ) + } + + if (expandFields.includes("regions")) { + let regions = {} + decorated.regions = await Promise.all( + decorated.regions.map(async r => { + if (r in regions) { + return regions[r] + } + const next = await this.regionService_.retrieve(r) + regions[r] = next + return next }) ) } diff --git a/packages/medusa/src/services/line-item.js b/packages/medusa/src/services/line-item.js index b7fec039cd..9ba5e487ca 100644 --- a/packages/medusa/src/services/line-item.js +++ b/packages/medusa/src/services/line-item.js @@ -37,15 +37,17 @@ class LineItemService extends BaseService { }) const lineItemSchema = Validator.object({ + _id: Validator.any().optional(), title: Validator.string().required(), - is_giftcard: Validator.bool().optional(), - should_merge: Validator.bool().optional(), description: Validator.string() .allow("") .optional(), thumbnail: Validator.string() .allow("") .optional(), + is_giftcard: Validator.bool().optional(), + should_merge: Validator.bool().optional(), + has_shipping: Validator.bool().optional(), content: Validator.alternatives() .try(content, Validator.array().items(content)) .required(), @@ -53,6 +55,18 @@ class LineItemService extends BaseService { .integer() .min(1) .required(), + returned: Validator.bool().optional(), + fulfilled: Validator.bool().optional(), + shipped: Validator.bool().optional(), + fulfilled_quantity: Validator.number() + .integer() + .optional(), + returned_quantity: Validator.number() + .integer() + .optional(), + shipped_quantity: Validator.number() + .integer() + .optional(), metadata: Validator.object().default({}), }) diff --git a/packages/medusa/src/services/order.js b/packages/medusa/src/services/order.js index 20994c5127..aba1b61013 100644 --- a/packages/medusa/src/services/order.js +++ b/packages/medusa/src/services/order.js @@ -300,92 +300,78 @@ class OrderService extends BaseService { ) } - // Throw if payment method does not exist - if (!cart.payment_method) { - throw new MedusaError( - MedusaError.Types.INVALID_ARGUMENT, - "Cart does not contain a payment method" - ) - } + const total = await this.totalsService_.getTotal(cart) + let paymentSession = {} + let paymentData = {} const { payment_method, payment_sessions } = cart - if (!payment_sessions || !payment_sessions.length) { - throw new MedusaError( - MedusaError.Types.INVALID_ARGUMENT, - "cart must have payment sessions" + // Would be the case if a discount code is applied that covers the item + // total + if (total !== 0) { + // Throw if payment method does not exist + if (!payment_method) { + throw new MedusaError( + MedusaError.Types.INVALID_ARGUMENT, + "Cart does not contain a payment method" + ) + } + + if (!payment_sessions || !payment_sessions.length) { + throw new MedusaError( + MedusaError.Types.INVALID_ARGUMENT, + "cart must have payment sessions" + ) + } + + paymentSession = payment_sessions.find( + ps => ps.provider_id === payment_method.provider_id ) - } - let paymentSession = payment_sessions.find( - ps => ps.provider_id === payment_method.provider_id - ) + // Throw if payment method does not exist + if (!paymentSession) { + throw new MedusaError( + MedusaError.Types.INVALID_ARGUMENT, + "Cart does not have an authorized payment session" + ) + } - // Throw if payment method does not exist - if (!paymentSession) { - throw new MedusaError( - MedusaError.Types.INVALID_ARGUMENT, - "Cart does not have an authorized payment session" + const paymentProvider = this.paymentProviderService_.retrieveProvider( + paymentSession.provider_id + ) + const paymentStatus = await paymentProvider.getStatus( + paymentSession.data + ) + + // If payment status is not authorized, we throw + if (paymentStatus !== "authorized" && paymentStatus !== "succeeded") { + throw new MedusaError( + MedusaError.Types.INVALID_ARGUMENT, + "Payment method is not authorized" + ) + } + + paymentData = await paymentProvider.retrievePayment( + paymentSession.data ) } const region = await this.regionService_.retrieve(cart.region_id) - const paymentProvider = this.paymentProviderService_.retrieveProvider( - paymentSession.provider_id - ) - const paymentStatus = await paymentProvider.getStatus( - paymentSession.data - ) - // If payment status is not authorized, we throw - if (paymentStatus !== "authorized" && paymentStatus !== "succeeded") { - throw new MedusaError( - MedusaError.Types.INVALID_ARGUMENT, - "Payment method is not authorized" - ) + let payment = {} + if (paymentSession.provider_id) { + payment = { + provider_id: paymentSession.provider_id, + data: paymentData, + } } - const paymentData = await paymentProvider.retrievePayment( - 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, { - line_item: i, - 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 = { display_id: await this.counterService_.getNext("orders"), - payment_method: { - provider_id: paymentSession.provider_id, - data: paymentData, - }, + payment_method: payment, discounts: cart.discounts, shipping_methods: cart.shipping_methods, - items, + items: cart.items, shipping_address: cart.shipping_address, billing_address: cart.shipping_address, region_id: cart.region_id, @@ -397,9 +383,11 @@ class OrderService extends BaseService { metadata: cart.metadata || {}, } - const orderDocument = await this.orderModel_.create([o], { - session: dbSession, - }) + const orderDocument = await this.orderModel_ + .create([o], { + session: dbSession, + }) + .catch(err => console.log(err)) // Emit and return this.eventBus_.emit(OrderService.Events.PLACED, orderDocument[0]) @@ -1264,6 +1252,7 @@ class OrderService extends BaseService { o.refunded_total = await this.totalsService_.getRefundedTotal(order) o.refundable_amount = o.total - o.refunded_total o.created = order._id.getTimestamp() + if (expandFields.includes("region")) { o.region = await this.regionService_.retrieve(order.region_id) } diff --git a/packages/medusa/src/services/totals.js b/packages/medusa/src/services/totals.js index fe361e58a9..8c655766df 100644 --- a/packages/medusa/src/services/totals.js +++ b/packages/medusa/src/services/totals.js @@ -199,7 +199,10 @@ class TotalsService extends BaseService { if (type === "percentage") { percentage = value / 100 } else if (type === "fixed") { - percentage = value / subtotal + // If the fixed discount exceeds the subtotal we should + // calculate a 100% discount + const nominator = Math.min(value, subtotal) + percentage = nominator / subtotal } return cart.items.map(item => { @@ -275,7 +278,7 @@ class TotalsService extends BaseService { if (type === "percentage" && allocation === "total") { toReturn = (subtotal / 100) * value } else if (type === "percentage" && allocation === "item") { - const itemPercentageDiscounts = await this.getAllocationItemDiscounts( + const itemPercentageDiscounts = this.getAllocationItemDiscounts( discount, cart, "percentage" @@ -284,7 +287,7 @@ class TotalsService extends BaseService { } else if (type === "fixed" && allocation === "total") { toReturn = value } else if (type === "fixed" && allocation === "item") { - const itemFixedDiscounts = await this.getAllocationItemDiscounts( + const itemFixedDiscounts = this.getAllocationItemDiscounts( discount, cart, "fixed" diff --git a/packages/medusa/src/subscribers/order.js b/packages/medusa/src/subscribers/order.js index 9ffe007087..cfb07214c5 100644 --- a/packages/medusa/src/subscribers/order.js +++ b/packages/medusa/src/subscribers/order.js @@ -1,11 +1,12 @@ class OrderSubscriber { constructor({ paymentProviderService, - cartService, customerService, eventBusService, discountService, totalsService, + orderService, + regionService, }) { this.totalsService_ = totalsService @@ -15,7 +16,9 @@ class OrderSubscriber { this.discountService_ = discountService - this.cartService_ = cartService + this.orderService_ = orderService + + this.regionService_ = regionService this.eventBus_ = eventBusService @@ -31,6 +34,8 @@ class OrderSubscriber { }) this.eventBus_.subscribe("order.placed", this.handleDiscounts) + + this.eventBus_.subscribe("order.placed", this.handleGiftCards) } handleDiscounts = async order => { @@ -58,6 +63,40 @@ class OrderSubscriber { }) ) } + + handleGiftCards = async order => { + const region = await this.regionService_.retrieve(order.region_id) + const items = await Promise.all( + order.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("order.gift_card_created", { + line_item: i, + currency_code: region.currency_code, + tax_rate: region.tax_rate, + giftcard: result, + email: order.email, + }) + return result + }) + return { + ...i, + metadata: { + ...i.metadata, + giftcard: giftcard._id, + }, + } + } + return i + }) + ) + + return this.orderService_.update(order._id, { + items, + }) + } } export default OrderSubscriber