feat(medusa): adds support for gift cards (#132)

* fix(medusa-plugin-brightpearl): adds gift cards to gift card nominal code

* fix: allow orders with 0 total to be created

* fix: gift card ready

* tests passing

* docs: brightpearl comment

* docs: clearer REMEMBER note
This commit is contained in:
Sebastian Rindom
2020-10-28 15:55:50 +01:00
committed by GitHub
parent b33fcfa182
commit f2c62cd232
17 changed files with 316 additions and 113 deletions
+2 -1
View File
@@ -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)
```
@@ -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) {
@@ -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({})
})
})
})
@@ -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) {
@@ -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
}
}
+2
View File
@@ -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: {} },
}
}
+2 -2
View File
@@ -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: {} },
@@ -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 },
@@ -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: {} },
})
@@ -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)
@@ -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",
+32 -21
View File
@@ -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(),
+32 -2
View File
@@ -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
})
)
}
+16 -2
View File
@@ -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({}),
})
+61 -72
View File
@@ -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)
}
+6 -3
View File
@@ -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"
+41 -2
View File
@@ -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