feat: line item adjustments (#1319)

* add: crud services + model + totals

* fix: enforce unique constraint on line item adjustment model and update service (#1241)

* add: unique constraint on model + fix service

* fix: unique constraint

* fix: add cascade on delete + fix discount relation

* fix: remove optional unique prop

* add: tests for ensuring line item adjustment db constraints (#1279)

* add: tests for ensuring db constraints

* fix: use given when then

* feat: adjust cart to include line item adjustments (#1242)

* fix: cart service + cart tests

* fix: remaining tests

* fix: swap tests

* fix: add relationship + fix oas

* refactor: applyDiscount

* fix: refactor applyDiscount and fix + add unit tests

* fix: plugins tests

* feat: line item adjustments draft orders (#1243)

* fix: draft order tests

* fix: constraint

* fix: wrong variable name

* fix: unique constraint

* progress: add tests

* fix: add cascade on delete + fix discount relation

* fix: remove optional unique prop

* fix: cart removeLineItem + tests

* fix: cart unit tests

* fix: update snapshot

* remove: verbose option

* rename arg

Co-authored-by: Sebastian Rindom <skrindom@gmail.com>

* add: create adjustments for swap additional_items

* add: create adjustments for return lines

* fix: unit test for creating adjustment for additional_items

* fix: create adjustments only for non return items + no deletion when item is a return item

* add: integration tests

* refactor: use refreshAdjustments method

* refactor test

Co-authored-by: Sebastian Rindom <skrindom@gmail.com>

Co-authored-by: Sebastian Rindom <skrindom@gmail.com>

Co-authored-by: Sebastian Rindom <skrindom@gmail.com>
This commit is contained in:
Zakaria El AsriandSebastian Rindom authored and GitHub committed 2022-04-12 15:49:31 +02:00
1 parent 607a382b4e
commit 1cfeb5dbd8
45 files changed
+3290 -581

No files matched your search

@@ -46,11 +46,17 @@ export default (app) => {
return app
}
export const defaultAdminDraftOrdersRelations = ["order", "cart"]
export const defaultAdminDraftOrdersRelations = [
"order",
"cart",
"cart.items",
"cart.items.adjustments",
]
export const defaultAdminDraftOrdersCartRelations = [
"region",
"items",
"items.adjustments",
"payment",
"shipping_address",
"billing_address",
@@ -5,12 +5,15 @@ import { SwapServiceMock } from "../../../../../services/__mocks__/swap"
const defaultRelations = [
"order",
"additional_items",
"additional_items.adjustments",
"return_order",
"fulfillments",
"payment",
"shipping_address",
"shipping_methods",
"cart",
"cart.items",
"cart.items.adjustments",
]
const defaultFields = [
@@ -24,12 +24,15 @@ export default (app) => {
export const defaultAdminSwapRelations = [
"order",
"additional_items",
"additional_items.adjustments",
"return_order",
"fulfillments",
"payment",
"shipping_address",
"shipping_methods",
"cart",
"cart.items",
"cart.items.adjustments",
]
export const defaultAdminSwapFields = [
@@ -115,6 +115,7 @@ export const defaultStoreCartRelations = [
"gift_cards",
"region",
"items",
"items.adjustments",
"payment",
"shipping_address",
"billing_address",
+1
View File
@@ -14,6 +14,7 @@ export * from "./models/product-type-tax-rate"
export * from "./models/tax-rate"
export * from "./models/shipping-method-tax-line"
export * from "./models/line-item-tax-line"
export * from "./models/line-item-adjustment"
export * from "./models/address"
export * from "./models/cart"
export * from "./models/claim-image"
@@ -0,0 +1,39 @@
import { MigrationInterface, QueryRunner } from "typeorm"
export class addLineItemAdjustments1648600574750 implements MigrationInterface {
name = "addLineItemAdjustments1648600574750"
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(
`CREATE TABLE "line_item_adjustment" ("id" character varying NOT NULL, "item_id" character varying NOT NULL, "description" character varying NOT NULL, "discount_id" character varying, "amount" integer NOT NULL, "metadata" jsonb, CONSTRAINT "PK_2b1360103753df2dc8257c2c8c3" PRIMARY KEY ("id"))`
)
await queryRunner.query(
`CREATE INDEX "IDX_be9aea2ccf3567007b6227da4d" ON "line_item_adjustment" ("item_id") `
)
await queryRunner.query(
`CREATE INDEX "IDX_2f41b20a71f30e60471d7e3769" ON "line_item_adjustment" ("discount_id") `
)
await queryRunner.query(
`CREATE UNIQUE INDEX "IDX_bf701b88d2041392a288785ada" ON "line_item_adjustment" ("discount_id", "item_id") WHERE "discount_id" IS NOT NULL`
)
await queryRunner.query(
`ALTER TABLE "line_item_adjustment" ADD CONSTRAINT "FK_be9aea2ccf3567007b6227da4d2" FOREIGN KEY ("item_id") REFERENCES "line_item"("id") ON DELETE CASCADE ON UPDATE NO ACTION`
)
await queryRunner.query(
`ALTER TABLE "line_item_adjustment" ADD CONSTRAINT "FK_2f41b20a71f30e60471d7e3769c" FOREIGN KEY ("discount_id") REFERENCES "discount"("id") ON DELETE NO ACTION ON UPDATE NO ACTION`
)
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(
`ALTER TABLE "line_item_adjustment" DROP CONSTRAINT "FK_2f41b20a71f30e60471d7e3769c"`
)
await queryRunner.query(
`ALTER TABLE "line_item_adjustment" DROP CONSTRAINT "FK_be9aea2ccf3567007b6227da4d2"`
)
await queryRunner.query(`DROP INDEX "IDX_bf701b88d2041392a288785ada"`)
await queryRunner.query(`DROP INDEX "IDX_2f41b20a71f30e60471d7e3769"`)
await queryRunner.query(`DROP INDEX "IDX_be9aea2ccf3567007b6227da4d"`)
await queryRunner.query(`DROP TABLE "line_item_adjustment"`)
}
}
@@ -0,0 +1,61 @@
import {
Entity,
BeforeInsert,
Index,
Column,
ManyToOne,
JoinColumn,
PrimaryColumn,
OneToOne,
Unique,
} from "typeorm"
import { ulid } from "ulid"
import { DbAwareColumn } from "../utils/db-aware-column"
import { Discount } from "./discount"
import { LineItem } from "./line-item"
@Entity()
@Index(["discount_id", "item_id"], {
unique: true,
where: `"discount_id" IS NOT NULL`,
})
export class LineItemAdjustment {
@PrimaryColumn()
id: string
@Index()
@Column()
item_id: string
@ManyToOne(
() => LineItem,
(li) => li.adjustments,
{ onDelete: "CASCADE" }
)
@JoinColumn({ name: "item_id" })
item: LineItem
@Column()
description: string
@ManyToOne(() => Discount)
@JoinColumn({ name: "discount_id" })
discount: Discount
@Index()
@Column({ nullable: true })
discount_id: string
@Column({ type: "int" })
amount: number
@DbAwareColumn({ type: "jsonb", nullable: true })
metadata: any
@BeforeInsert()
private beforeInsert() {
if (this.id) return
const id = ulid()
this.id = `lia_${id}`
}
}
+4
View File
@@ -20,6 +20,7 @@ import { Cart } from "./cart"
import { Order } from "./order"
import { ClaimOrder } from "./claim-order"
import { ProductVariant } from "./product-variant"
import { LineItemAdjustment } from "./line-item-adjustment"
@Check(`"fulfilled_quantity" <= "quantity"`)
@Check(`"shipped_quantity" <= "fulfilled_quantity"`)
@@ -65,6 +66,9 @@ export class LineItem {
@OneToMany(() => LineItemTaxLine, (tl) => tl.item, { cascade: ["insert"] })
tax_lines: LineItemTaxLine[]
@OneToMany(() => LineItemAdjustment, (lia) => lia.item, { cascade: ["insert"] })
adjustments: LineItemAdjustment[]
@Column()
title: string
@@ -0,0 +1,6 @@
import { EntityRepository, Repository } from "typeorm"
import { LineItemAdjustment } from "../models/line-item-adjustment"
@EntityRepository(LineItemAdjustment)
export class LineItemAdjustmentRepository extends Repository<LineItemAdjustment> {}
@@ -17,6 +17,7 @@ export class LineItemRepository extends Repository<LineItem> {
): Promise<(LineItem & { return_item: ReturnItem })[]> {
const qb = this.createQueryBuilder("li")
.leftJoinAndSelect(`li.tax_lines`, "tax_lines")
.leftJoinAndSelect(`li.adjustments`, "adjustments")
.leftJoinAndMapOne(
`li.return_item`,
ReturnItem,
+13 -10
View File
@@ -210,7 +210,7 @@ export const CartServiceMock = {
withTransaction: function() {
return this
},
updatePaymentSession: jest.fn().mockImplementation(data => {
updatePaymentSession: jest.fn().mockImplementation((data) => {
return Promise.resolve()
}),
authorizePayment: jest.fn().mockImplementation((id, data) => {
@@ -223,13 +223,13 @@ export const CartServiceMock = {
}
return Promise.resolve(carts.testCart)
}),
refreshPaymentSession: jest.fn().mockImplementation(data => {
refreshPaymentSession: jest.fn().mockImplementation((data) => {
return Promise.resolve()
}),
update: jest.fn().mockImplementation(data => {
update: jest.fn().mockImplementation((data) => {
return Promise.resolve()
}),
create: jest.fn().mockImplementation(data => {
create: jest.fn().mockImplementation((data) => {
if (data.region_id === IdMap.getId("testRegion")) {
return Promise.resolve(carts.regionCart)
}
@@ -238,7 +238,7 @@ export const CartServiceMock = {
}
return Promise.resolve(carts.regionCart)
}),
retrieve: jest.fn().mockImplementation(cartId => {
retrieve: jest.fn().mockImplementation((cartId) => {
if (cartId === IdMap.getId("fr-cart")) {
return Promise.resolve(carts.frCart)
}
@@ -326,20 +326,20 @@ export const CartServiceMock = {
applyDiscount: jest.fn().mockImplementation((cartId, code) => {
return Promise.resolve()
}),
setPaymentSession: jest.fn().mockImplementation(cartId => {
setPaymentSession: jest.fn().mockImplementation((cartId) => {
return Promise.resolve()
}),
setPaymentSessions: jest.fn().mockImplementation(cartId => {
setPaymentSessions: jest.fn().mockImplementation((cartId) => {
return Promise.resolve()
}),
setShippingOptions: jest.fn().mockImplementation(cartId => {
setShippingOptions: jest.fn().mockImplementation((cartId) => {
return Promise.resolve()
}),
decorate: jest.fn().mockImplementation(cart => {
decorate: jest.fn().mockImplementation((cart) => {
cart.decorated = true
return cart
}),
addShippingMethod: jest.fn().mockImplementation(cartId => {
addShippingMethod: jest.fn().mockImplementation((cartId) => {
return Promise.resolve()
}),
retrieveShippingOption: jest.fn().mockImplementation((cartId, optionId) => {
@@ -378,6 +378,9 @@ export const CartServiceMock = {
}
}
}),
refreshAdjustments_: jest.fn().mockImplementation((cart) => {
return Promise.resolve({})
}),
}
const mock = jest.fn().mockImplementation(() => {
@@ -0,0 +1,68 @@
import { IdMap } from "medusa-test-utils"
import { MedusaError } from "medusa-core-utils"
export const LineItemAdjustmentServiceMock = {
withTransaction: function() {
return this
},
create: jest.fn().mockImplementation((data) => {
return Promise.resolve({ ...data })
}),
update: jest.fn().mockImplementation((data) => {
return Promise.resolve({ ...data })
}),
validate: jest.fn().mockImplementation((data) => {
if (data.title === "invalid lineitem") {
throw new Error(`"content" is required`)
}
return data
}),
delete: jest.fn().mockImplementation((data) => {
return Promise.resolve({})
}),
createAdjustmentForLineItem: jest
.fn()
.mockImplementation((cart, lineItem) => {
return Promise.resolve({
item_id: lineItem.id,
amount: 1000,
discount_id: "disc_2",
id: "lia-1",
description: "discount",
})
}),
createAdjustments: jest.fn().mockImplementation((cart, lineItem) => {
if (lineItem) {
return Promise.resolve({
item_id: lineItem.id,
amount: 1000,
discount_id: "disc_2",
id: "lia-1",
description: "discount",
})
}
return Promise.resolve([
{
item_id: "li-1",
amount: 200,
discount_id: "disc_2",
id: "lia-1",
description: "discount",
},
{
item_id: "li-3",
amount: 100,
discount_id: "disc_3",
id: "lia-2",
description: "discount",
},
])
}),
}
const mock = jest.fn().mockImplementation(() => {
return LineItemAdjustmentServiceMock
})
export default mock
+195 -111
View File
@@ -3,6 +3,7 @@ import { MedusaError } from "medusa-core-utils"
import { IdMap, MockManager, MockRepository } from "medusa-test-utils"
import CartService from "../cart"
import { InventoryServiceMock } from "../__mocks__/inventory"
import { LineItemAdjustmentServiceMock } from "../__mocks__/line-item-adjustment"
const eventBusService = {
emit: jest.fn(),
@@ -347,6 +348,7 @@ describe("CartService", () => {
findOneWithRelations: (rels, q) => {
if (q.where.id === IdMap.getId("cartWithLine")) {
return Promise.resolve({
id: IdMap.getId("cartWithLine"),
items: [
{
id: IdMap.getId("merger"),
@@ -359,6 +361,7 @@ describe("CartService", () => {
})
}
return Promise.resolve({
id: IdMap.getId("emptyCart"),
shipping_methods: [
{
shipping_option: {
@@ -378,13 +381,14 @@ describe("CartService", () => {
eventBusService,
shippingOptionService,
inventoryService,
lineItemAdjustmentService: LineItemAdjustmentServiceMock,
})
beforeEach(() => {
jest.clearAllMocks()
})
it("successfully creates new line item", async () => {
it("creates a new line item and emits a created event", async () => {
const lineItem = {
title: "New Line",
description: "This is a new line",
@@ -393,7 +397,6 @@ describe("CartService", () => {
unit_price: 123,
quantity: 10,
}
await cartService.addLineItem(IdMap.getId("emptyCart"), _.clone(lineItem))
expect(eventBusService.emit).toHaveBeenCalledTimes(1)
@@ -408,6 +411,15 @@ describe("CartService", () => {
has_shipping: false,
cart_id: IdMap.getId("emptyCart"),
})
expect(
LineItemAdjustmentServiceMock.createAdjustments
).toHaveBeenCalledTimes(1)
expect(
LineItemAdjustmentServiceMock.createAdjustments
).toHaveBeenCalledWith(
expect.objectContaining({ id: IdMap.getId("emptyCart") })
)
})
it("successfully creates new line item with shipping", async () => {
@@ -440,6 +452,15 @@ describe("CartService", () => {
has_shipping: false,
cart_id: IdMap.getId("emptyCart"),
})
expect(
LineItemAdjustmentServiceMock.createAdjustments
).toHaveBeenCalledTimes(1)
expect(
LineItemAdjustmentServiceMock.createAdjustments
).toHaveBeenCalledWith(
expect.objectContaining({ id: IdMap.getId("emptyCart") })
)
})
it("successfully merges existing line item", async () => {
@@ -462,6 +483,20 @@ describe("CartService", () => {
quantity: 2,
}
)
expect(LineItemAdjustmentServiceMock.delete).toHaveBeenCalledTimes(1)
expect(LineItemAdjustmentServiceMock.delete).toHaveBeenCalledWith({
item_id: [IdMap.getId("merger")],
})
expect(
LineItemAdjustmentServiceMock.createAdjustments
).toHaveBeenCalledTimes(1)
expect(
LineItemAdjustmentServiceMock.createAdjustments
).toHaveBeenCalledWith(
expect.objectContaining({ id: IdMap.getId("cartWithLine") })
)
})
it("throws if inventory isn't covered", async () => {
@@ -559,6 +594,7 @@ describe("CartService", () => {
lineItemService,
shippingOptionService,
eventBusService,
lineItemAdjustmentService: LineItemAdjustmentServiceMock,
})
beforeEach(() => {
@@ -576,6 +612,22 @@ describe("CartService", () => {
IdMap.getId("itemToRemove")
)
expect(LineItemAdjustmentServiceMock.delete).toHaveBeenCalledTimes(1)
expect(LineItemAdjustmentServiceMock.delete).toHaveBeenCalledWith({
item_id: [IdMap.getId("itemToRemove")],
})
expect(
LineItemAdjustmentServiceMock.createAdjustments
).toHaveBeenCalledTimes(1)
expect(
LineItemAdjustmentServiceMock.createAdjustments
).toHaveBeenCalledWith(
expect.objectContaining({
items: [{ id: IdMap.getId("itemToRemove") }],
})
)
expect(eventBusService.emit).toHaveBeenCalledTimes(1)
expect(eventBusService.emit).toHaveBeenCalledWith(
"cart.updated",
@@ -598,6 +650,22 @@ describe("CartService", () => {
profile_id: IdMap.getId("prevPro"),
},
})
expect(LineItemAdjustmentServiceMock.delete).toHaveBeenCalledTimes(1)
expect(LineItemAdjustmentServiceMock.delete).toHaveBeenCalledWith({
item_id: [IdMap.getId("itemToRemove")],
})
expect(
LineItemAdjustmentServiceMock.createAdjustments
).toHaveBeenCalledTimes(1)
expect(
LineItemAdjustmentServiceMock.createAdjustments
).toHaveBeenCalledWith(
expect.objectContaining({
items: [expect.objectContaining({ id: IdMap.getId("itemToRemove") })],
})
)
})
it("resolves if line item is not in cart", async () => {
@@ -666,7 +734,13 @@ describe("CartService", () => {
describe("updateLineItem", () => {
const lineItemService = {
update: jest.fn(),
update: jest.fn().mockImplementation(() =>
Promise.resolve({
id: IdMap.getId("existing"),
variant_id: IdMap.getId("good"),
quantity: 1,
})
),
withTransaction: function() {
return this
},
@@ -692,6 +766,7 @@ describe("CartService", () => {
})
}
return Promise.resolve({
id: IdMap.getId("cartWithLine"),
items: [
{
id: IdMap.getId("existing"),
@@ -709,6 +784,7 @@ describe("CartService", () => {
lineItemService,
eventBusService,
inventoryService,
lineItemAdjustmentService: LineItemAdjustmentServiceMock,
})
beforeEach(() => {
@@ -733,6 +809,20 @@ describe("CartService", () => {
IdMap.getId("existing"),
{ quantity: 2 }
)
expect(LineItemAdjustmentServiceMock.delete).toHaveBeenCalledTimes(1)
expect(LineItemAdjustmentServiceMock.delete).toHaveBeenCalledWith({
item_id: [IdMap.getId("existing")],
})
expect(
LineItemAdjustmentServiceMock.createAdjustments
).toHaveBeenCalledTimes(1)
expect(
LineItemAdjustmentServiceMock.createAdjustments
).toHaveBeenCalledWith(
expect.objectContaining({ id: IdMap.getId("cartWithLine") })
)
})
it("throws if inventory isn't covered", async () => {
@@ -1599,6 +1689,18 @@ describe("CartService", () => {
},
],
region_id: IdMap.getId("good"),
items: [
{
id: "li1",
quantity: 2,
unit_price: 1000,
},
{
id: "li2",
quantity: 1,
unit_price: 500,
},
],
})
}
if (q.where.id === "with-d-and-customer") {
@@ -1619,6 +1721,18 @@ describe("CartService", () => {
id: IdMap.getId("cart"),
discounts: [],
region_id: IdMap.getId("good"),
items: [
{
id: "li1",
quantity: 2,
unit_price: 1000,
},
{
id: "li2",
quantity: 1,
unit_price: 500,
},
],
})
},
})
@@ -1756,6 +1870,11 @@ describe("CartService", () => {
}
return Promise.resolve(false)
}),
validateDiscountForCartOrThrow: jest
.fn()
.mockImplementation((cart, discount) => {
return Promise.resolve({ hasErrors: () => false })
}),
}
const cartService = new CartService({
@@ -1764,6 +1883,7 @@ describe("CartService", () => {
cartRepository,
discountService,
eventBusService,
lineItemAdjustmentService: LineItemAdjustmentServiceMock,
})
beforeEach(async () => {
@@ -1793,6 +1913,18 @@ describe("CartService", () => {
subtotal: 0,
tax_total: 0,
total: 0,
items: [
{
id: "li1",
quantity: 2,
unit_price: 1000,
},
{
id: "li2",
quantity: 1,
unit_price: 500,
},
],
discounts: [
{
id: IdMap.getId("10off"),
@@ -1804,6 +1936,20 @@ describe("CartService", () => {
},
],
})
expect(LineItemAdjustmentServiceMock.delete).toHaveBeenCalledTimes(1)
expect(LineItemAdjustmentServiceMock.delete).toHaveBeenCalledWith({
item_id: ["li1", "li2"],
})
expect(
LineItemAdjustmentServiceMock.createAdjustments
).toHaveBeenCalledTimes(1)
expect(
LineItemAdjustmentServiceMock.createAdjustments
).toHaveBeenCalledWith(
expect.objectContaining({ id: IdMap.getId("cart") })
)
})
it("successfully applies discount to cart and removes old one", async () => {
@@ -1820,6 +1966,18 @@ describe("CartService", () => {
subtotal: 0,
tax_total: 0,
total: 0,
items: [
{
id: "li1",
quantity: 2,
unit_price: 1000,
},
{
id: "li2",
quantity: 1,
unit_price: 500,
},
],
discounts: [
{
id: IdMap.getId("10off"),
@@ -1831,6 +1989,20 @@ describe("CartService", () => {
},
],
})
expect(LineItemAdjustmentServiceMock.delete).toHaveBeenCalledTimes(1)
expect(LineItemAdjustmentServiceMock.delete).toHaveBeenCalledWith({
item_id: ["li1", "li2"],
})
expect(
LineItemAdjustmentServiceMock.createAdjustments
).toHaveBeenCalledTimes(1)
expect(
LineItemAdjustmentServiceMock.createAdjustments
).toHaveBeenCalledWith(
expect.objectContaining({ id: IdMap.getId("cart") })
)
})
it("successfully applies free shipping", async () => {
@@ -1860,32 +2032,16 @@ describe("CartService", () => {
},
},
],
discount_total: 0,
shipping_total: 0,
subtotal: 0,
tax_total: 0,
total: 0,
region_id: IdMap.getId("good"),
})
})
it("successfully applies discount with usage count null", async () => {
await cartService.update(IdMap.getId("cart"), {
discounts: [{ code: "null-count" }],
})
expect(discountService.retrieveByCode).toHaveBeenCalledTimes(1)
expect(cartRepository.save).toHaveBeenCalledTimes(1)
expect(cartRepository.save).toHaveBeenCalledWith({
id: IdMap.getId("cart"),
discounts: [
items: [
{
id: IdMap.getId("null-count"),
code: "null-count",
regions: [{ id: IdMap.getId("good") }],
usage_count: 0,
usage_limit: 2,
rule: {},
id: "li1",
quantity: 2,
unit_price: 1000,
},
{
id: "li2",
quantity: 1,
unit_price: 500,
},
],
discount_total: 0,
@@ -1895,92 +2051,20 @@ describe("CartService", () => {
total: 0,
region_id: IdMap.getId("good"),
})
})
it("successfully applies valid discount with expiration date to cart", async () => {
await cartService.update(IdMap.getId("fr-cart"), {
discounts: [
{
code: "ValidDiscount",
},
],
expect(LineItemAdjustmentServiceMock.delete).toHaveBeenCalledTimes(1)
expect(LineItemAdjustmentServiceMock.delete).toHaveBeenCalledWith({
item_id: ["li1", "li2"],
})
expect(eventBusService.emit).toHaveBeenCalledTimes(1)
expect(eventBusService.emit).toHaveBeenCalledWith(
"cart.updated",
expect.any(Object)
expect(
LineItemAdjustmentServiceMock.createAdjustments
).toHaveBeenCalledTimes(1)
expect(
LineItemAdjustmentServiceMock.createAdjustments
).toHaveBeenCalledWith(
expect.objectContaining({ id: IdMap.getId("cart") })
)
expect(cartRepository.save).toHaveBeenCalledTimes(1)
expect(cartRepository.save).toHaveBeenCalledWith({
id: IdMap.getId("cart"),
region_id: IdMap.getId("good"),
discount_total: 0,
shipping_total: 0,
subtotal: 0,
tax_total: 0,
total: 0,
discounts: [
{
id: IdMap.getId("10off"),
code: "10%OFF",
regions: [{ id: IdMap.getId("good") }],
rule: {
type: "percentage",
},
starts_at: expect.any(Date),
ends_at: expect.any(Date),
},
],
})
})
it("throws if discount is applied too before it's valid", async () => {
await expect(
cartService.update(IdMap.getId("cart"), {
discounts: [{ code: "EarlyDiscount" }],
})
).rejects.toThrow("Discount is not valid yet")
})
it("throws if expired discount is applied", async () => {
await expect(
cartService.update(IdMap.getId("cart"), {
discounts: [{ code: "ExpiredDiscount" }],
})
).rejects.toThrow("Discount is expired")
})
it("throws if expired dynamic discount is applied", async () => {
await expect(
cartService.update(IdMap.getId("cart"), {
discounts: [{ code: "ExpiredDynamicDiscount" }],
})
).rejects.toThrow("Discount is expired")
})
it("throws if expired dynamic discount is applied after ends_at", async () => {
await expect(
cartService.update(IdMap.getId("cart"), {
discounts: [{ code: "ExpiredDynamicDiscountEndDate" }],
})
).rejects.toThrow("Discount is expired")
})
it("throws if discount limit is reached", async () => {
await expect(
cartService.update(IdMap.getId("cart"), {
discounts: [{ code: "limit-reached" }],
})
).rejects.toThrow("Discount has been used maximum allowed times")
})
it("throws if discount is not available in region", async () => {
await expect(
cartService.update(IdMap.getId("cart"), {
discounts: [{ code: "US10" }],
})
).rejects.toThrow("The discount is not available in current region")
})
it("successfully applies discount with a check for customer applicableness", async () => {
@@ -682,6 +682,472 @@ describe("DiscountService", () => {
})
})
describe("validateDiscountForCartOrThrow", () => {
const getCart = (id) => {
if (id === "with-d") {
return {
id: "cart",
discounts: [
{
code: "1234",
rule: {
type: "fixed",
},
},
{
code: "FS1234",
rule: {
type: "free_shipping",
},
},
],
region_id: "good",
items: [
{
id: "li1",
quantity: 2,
unit_price: 1000,
},
{
id: "li2",
quantity: 1,
unit_price: 500,
},
],
}
}
if (id === "with-d-and-customer") {
return {
id: "with-d-and-customer",
discounts: [
{
code: "ApplicableForCustomer",
rule: {
type: "fixed",
},
},
],
region_id: "good",
customer_id: "test-customer",
}
}
return {
id: "cart",
discounts: [
{
code: "CODE",
rule: {
type: "percentage",
},
},
],
region_id: "good",
items: [
{
id: "li1",
quantity: 2,
unit_price: 1000,
},
{
id: "li2",
quantity: 1,
unit_price: 500,
},
],
}
}
let discountService
beforeEach(async () => {
discountService = new DiscountService({})
const hasReachedLimitMock = jest.fn().mockImplementation(() => false)
const isDisabledMock = jest.fn().mockImplementation(() => false)
const isValidForRegionMock = jest
.fn()
.mockImplementation(() => Promise.resolve(true))
const canApplyForCustomerMock = jest.fn().mockImplementation(() => {
return Promise.resolve(true)
})
discountService.hasReachedLimit = hasReachedLimitMock
discountService.isDisabled = isDisabledMock
discountService.canApplyForCustomer = canApplyForCustomerMock
discountService.isValidForRegion = isValidForRegionMock
})
it("calls all validation methods when cart includes a customer_id and doesn't throw", async () => {
const discount = {
id: "10off",
code: "10%OFF",
regions: [{ id: "good" }],
rule: {
id: "10off-rule",
type: "percentage",
},
}
const cart = getCart("with-d-and-customer")
const result = await discountService.validateDiscountForCartOrThrow(
cart,
discount
)
expect(result).toBeUndefined()
expect(discountService.hasReachedLimit).toHaveBeenCalledTimes(1)
expect(discountService.hasReachedLimit).toHaveBeenCalledWith(discount)
expect(discountService.isDisabled).toHaveBeenCalledTimes(1)
expect(discountService.isDisabled).toHaveBeenCalledWith(discount)
expect(discountService.isValidForRegion).toHaveBeenCalledTimes(1)
expect(discountService.isValidForRegion).toHaveBeenCalledWith(
discount,
cart.region_id
)
expect(discountService.canApplyForCustomer).toHaveBeenCalledTimes(1)
expect(discountService.canApplyForCustomer).toHaveBeenCalledWith(
discount.rule.id,
cart.customer_id
)
})
it("throws when hasReachedLimit returns true", async () => {
const discount = {}
const cart = getCart("with-d-and-customer")
discountService.hasReachedLimit = jest.fn().mockImplementation(() => true)
expect(
discountService.validateDiscountForCartOrThrow(cart, discount)
).rejects.toThrow({
message: "Discount has been used maximum allowed times",
})
})
it("throws when hasNotStarted returns true", async () => {
const discount = {}
const cart = getCart("with-d-and-customer")
discountService.hasNotStarted = jest.fn().mockImplementation(() => true)
expect(
discountService.validateDiscountForCartOrThrow(cart, discount)
).rejects.toThrow({
message: "Discount is not valid yet",
})
})
it("throws when hasExpired returns true", async () => {
const discount = {}
const cart = getCart("with-d-and-customer")
discountService.hasExpired = jest.fn().mockImplementation(() => true)
expect(
discountService.validateDiscountForCartOrThrow(cart, discount)
).rejects.toThrow({
message: "Discount is expired",
})
})
it("throws when isDisabled returns true", async () => {
const discount = {}
const cart = getCart("with-d-and-customer")
discountService.isDisabled = jest.fn().mockImplementation(() => true)
expect(
discountService.validateDiscountForCartOrThrow(cart, discount)
).rejects.toThrow({
message: "The discount code is disabled",
})
})
it("throws when isValidForRegion returns false", async () => {
const discount = {}
const cart = getCart("with-d-and-customer")
discountService.isValidForRegion = jest
.fn()
.mockImplementation(() => Promise.resolve(false))
expect(
discountService.validateDiscountForCartOrThrow(cart, discount)
).rejects.toThrow({
message: "The discount is not available in current region",
})
})
it("throws when canApplyForCustomer returns false", async () => {
const discount = { rule: { id: "" } }
const cart = getCart("with-d-and-customer")
discountService.canApplyForCustomer = jest
.fn()
.mockImplementation(() => Promise.resolve(false))
expect(
discountService.validateDiscountForCartOrThrow(cart, discount)
).rejects.toThrow({
message: "Discount is not valid for customer",
})
})
})
describe("hasReachedLimit", () => {
const discountService = new DiscountService({})
it("returns true if discount limit is reached", () => {
const discount = {
id: "limit-reached",
code: "limit-reached",
regions: [{ id: "good" }],
rule: {},
usage_count: 2,
usage_limit: 2,
}
const hasReachedLimit = discountService.hasReachedLimit(discount)
expect(hasReachedLimit).toBe(true)
})
it("returns false if discount limit is not reached", () => {
const discount = {
id: "limit-reached",
code: "limit-reached",
regions: [{ id: "good" }],
rule: {},
usage_count: 1,
usage_limit: 100,
}
const hasReachedLimit = discountService.hasReachedLimit(discount)
expect(hasReachedLimit).toBe(false)
})
it("returns false if discount limit is not set", () => {
const discount = {
id: "limit-reached",
code: "limit-reached",
regions: [{ id: "good" }],
rule: {},
usage_count: 1,
}
const hasReachedLimit = discountService.hasReachedLimit(discount)
expect(hasReachedLimit).toBe(false)
})
})
describe("isDisabled", () => {
const discountService = new DiscountService({})
it("returns false if discount not disabled", async () => {
const discount = {
id: "10off",
code: "10%OFF",
regions: [{ id: "good" }],
rule: {
id: "10off-rule",
type: "percentage",
},
is_disabled: false,
}
const isDisabled = discountService.isDisabled(discount)
expect(isDisabled).toBe(false)
})
it("returns true if discount is disabled", async () => {
const discount = {
id: "10off",
code: "10%OFF",
regions: [{ id: "good" }],
rule: {
id: "10off-rule",
type: "percentage",
},
is_disabled: true,
}
const isDisabled = discountService.isDisabled(discount)
expect(isDisabled).toBe(true)
})
})
describe("hasNotStarted", () => {
const discountService = new DiscountService({})
it("returns true if discount has a future starts_at date", async () => {
const discount = {
id: "10off",
code: "10%OFF",
regions: [{ id: "good" }],
rule: {
type: "percentage",
},
starts_at: getOffsetDate(1),
ends_at: getOffsetDate(10),
}
const hasNotStarted = discountService.hasNotStarted(discount)
expect(hasNotStarted).toBe(true)
})
it("returns false if discount has a past starts_at date", async () => {
const discount = {
id: "10off",
code: "10%OFF",
regions: [{ id: "good" }],
rule: {
type: "percentage",
},
starts_at: getOffsetDate(-2),
ends_at: getOffsetDate(10),
}
const hasNotStarted = discountService.hasNotStarted(discount)
expect(hasNotStarted).toBe(false)
})
})
describe("hasExpired", () => {
const discountService = new DiscountService({})
it("returns false if discount has a future ends_at date", async () => {
const discount = {
id: "10off",
code: "10%OFF",
regions: [{ id: "good" }],
rule: {
type: "percentage",
},
ends_at: getOffsetDate(10),
starts_at: getOffsetDate(-1),
}
const hasExpired = discountService.hasExpired(discount)
expect(hasExpired).toBe(false)
})
it("returns true if discount has a past ends_at date", async () => {
const discount = {
id: "10off",
code: "10%OFF",
regions: [{ id: "good" }],
rule: {
type: "percentage",
},
starts_at: getOffsetDate(-10),
ends_at: getOffsetDate(-1),
}
const hasExpired = discountService.hasExpired(discount)
expect(hasExpired).toBe(true)
})
})
describe("isValidForRegion", () => {
const retrieveMock = jest.fn().mockImplementation((id) => {
if (id === "parent-discount-us") {
return Promise.resolve({
id,
regions: [{ id: "us" }],
rule: {
id: "10off-rule",
type: "percentage",
},
})
} else if (id === "parent-discount-dk") {
return Promise.resolve({
id,
regions: [{ id: "dk" }],
rule: {
id: "10off-rule",
type: "percentage",
},
})
}
})
const discountService = new DiscountService({})
discountService.retrieve = retrieveMock
beforeEach(() => {
jest.clearAllMocks()
})
it("returns false if discount is not available in a given region", async () => {
const discount = {
id: "10off",
code: "10%OFF",
regions: [{ id: "us" }],
rule: {
id: "10off-rule",
type: "percentage",
},
}
const isValidForRegion = await discountService.isValidForRegion(
discount,
"dk"
)
expect(retrieveMock).toBeCalledTimes(0)
expect(isValidForRegion).toBe(false)
})
it("returns true if discount is available in a given region", async () => {
const discount = {
id: "10off",
code: "10%OFF",
regions: [{ id: "us" }],
rule: {
id: "10off-rule",
type: "percentage",
},
}
const isValidForRegion = await discountService.isValidForRegion(
discount,
"us"
)
expect(retrieveMock).toBeCalledTimes(0)
expect(isValidForRegion).toBe(true)
})
it("returns false if discount has a parent discount and is not available in region", async () => {
const discount = {
id: "10off",
code: "10%OFF",
parent_discount_id: "parent-discount-us",
}
const isValidForRegion = await discountService.isValidForRegion(
discount,
"dk"
)
expect(retrieveMock).toBeCalledTimes(1)
expect(retrieveMock).toBeCalledWith(discount.parent_discount_id, {
relations: ["rule", "regions"],
})
expect(isValidForRegion).toBe(false)
})
it("returns true if discount has a parent discount and is available in region", async () => {
const discount = {
id: "10off",
code: "10%OFF",
parent_discount_id: "parent-discount-dk",
}
const isValidForRegion = await discountService.isValidForRegion(
discount,
"dk"
)
expect(retrieveMock).toBeCalledTimes(1)
expect(retrieveMock).toBeCalledWith(discount.parent_discount_id, {
relations: ["rule", "regions"],
})
expect(isValidForRegion).toBe(true)
})
})
describe("canApplyForCustomer", () => {
const discountConditionRepository = {
canApplyForCustomer: jest
@@ -736,3 +1202,9 @@ describe("DiscountService", () => {
})
})
})
const getOffsetDate = (offset) => {
const date = new Date()
date.setDate(date.getDate() + offset)
return date
}
@@ -12,22 +12,22 @@ const eventBusService = {
describe("DraftOrderService", () => {
const totalsService = {
getTotal: o => {
getTotal: (o) => {
return o.total || 0
},
getSubtotal: o => {
getSubtotal: (o) => {
return o.subtotal || 0
},
getTaxTotal: o => {
getTaxTotal: (o) => {
return o.tax_total || 0
},
getDiscountTotal: o => {
getDiscountTotal: (o) => {
return o.discount_total || 0
},
getShippingTotal: o => {
getShippingTotal: (o) => {
return o.shipping_total || 0
},
getGiftCardTotal: o => {
getGiftCardTotal: (o) => {
return o.gift_card_total || 0
},
}
@@ -83,7 +83,7 @@ describe("DraftOrderService", () => {
}
const cartService = {
create: jest.fn().mockImplementation(data =>
create: jest.fn().mockImplementation((data) =>
Promise.resolve({
id: "test-cart",
...data,
@@ -110,16 +110,16 @@ describe("DraftOrderService", () => {
}
const addressRepository = MockRepository({
create: addr => ({
create: (addr) => ({
...addr,
}),
})
const draftOrderRepository = MockRepository({
create: d => ({
create: (d) => ({
...d,
}),
save: d => ({
save: (d) => ({
id: "test-draft-order",
...d,
}),
@@ -170,7 +170,13 @@ describe("DraftOrderService", () => {
"test-variant",
"test-region",
2,
{ metadata: {}, unit_price: undefined }
{
metadata: {},
unit_price: undefined,
cart: expect.objectContaining({
id: "test-cart",
}),
}
)
expect(lineItemService.create).toHaveBeenCalledTimes(1)
@@ -228,14 +234,14 @@ describe("DraftOrderService", () => {
}
const draftOrderRepository = MockRepository({
create: d => ({
create: (d) => ({
...d,
}),
save: d => ({
save: (d) => ({
id: "test-draft-order",
...d,
}),
findOne: q => {
findOne: (q) => {
switch (q.where.id) {
case "completed":
return Promise.resolve(completedOrder)
@@ -0,0 +1,314 @@
import LineItemAdjustmentService from "../line-item-adjustment"
import { MockManager, MockRepository, IdMap } from "medusa-test-utils"
import { EventBusServiceMock } from "../__mocks__/event-bus"
import { DiscountServiceMock } from "../__mocks__/discount"
import { In } from "typeorm"
describe("LineItemAdjustmentService", () => {
describe("list", () => {
const lineItemAdjustmentRepo = MockRepository({
find: (q) => {
return Promise.resolve([
{
id: "lia-1",
description: "discount",
amount: 1000,
item: "li-1",
discount_id: "disc_1",
},
])
},
})
const lineItemAdjustmentService = new LineItemAdjustmentService({
manager: MockManager,
lineItemAdjustmentRepository: lineItemAdjustmentRepo,
})
beforeAll(async () => {
jest.clearAllMocks()
})
it("calls lineItemAdjustment list method", async () => {
await lineItemAdjustmentService.list(
{ item_id: "li-1" },
{
relations: ["item"],
}
)
expect(lineItemAdjustmentRepo.find).toHaveBeenCalledTimes(1)
expect(lineItemAdjustmentRepo.find).toHaveBeenCalledWith({
where: {
item_id: "li-1",
},
relations: ["item"],
})
})
})
describe("retrieve", () => {
const lineItemAdjustmentRepo = MockRepository({
findOne: (q) => {
switch (q.where.id) {
case "lia-1":
return Promise.resolve({
id: "lia-1",
description: "discount",
})
default:
return Promise.resolve()
}
},
})
const lineItemAdjustmentService = new LineItemAdjustmentService({
manager: MockManager,
lineItemAdjustmentRepository: lineItemAdjustmentRepo,
})
beforeAll(async () => {
jest.clearAllMocks()
})
it("calls lineItemAdjustment retrieve method", async () => {
await lineItemAdjustmentService.retrieve("lia-1", {
relations: ["item"],
})
expect(lineItemAdjustmentRepo.findOne).toHaveBeenCalledTimes(1)
expect(lineItemAdjustmentRepo.findOne).toHaveBeenCalledWith({
where: { id: "lia-1" },
relations: ["item"],
})
})
it("fails when lineItemAdjustment is not found", async () => {
await expect(
lineItemAdjustmentService.retrieve("not-existing")
).rejects.toThrow(
`Line item adjustment with id: not-existing was not found`
)
})
})
describe("create", () => {
const lineItemAdjustment = {
id: "lia-1",
amount: 2000,
description: "discount",
item_id: "li-3",
discount_id: "disc_999",
}
const lineItemAdjustmentRepo = MockRepository({
create: (f) => Promise.resolve(lineItemAdjustment),
save: (f) => Promise.resolve(lineItemAdjustment),
})
const lineItemAdjustmentService = new LineItemAdjustmentService({
manager: MockManager,
lineItemAdjustmentRepository: lineItemAdjustmentRepo,
eventBusService: EventBusServiceMock,
})
beforeAll(async () => {
jest.clearAllMocks()
})
it("calls lineItemAdjustment create method", async () => {
await lineItemAdjustmentService.create({
amount: 2000,
description: "discount",
item_id: "li-3",
discount_id: "disc_999",
})
expect(lineItemAdjustmentRepo.create).toHaveBeenCalledTimes(1)
expect(lineItemAdjustmentRepo.create).toHaveBeenCalledWith({
amount: 2000,
description: "discount",
item_id: "li-3",
discount_id: "disc_999",
})
expect(lineItemAdjustmentRepo.save).toHaveBeenCalledTimes(1)
expect(lineItemAdjustmentRepo.save).toHaveBeenCalledWith({
id: "lia-1",
amount: 2000,
description: "discount",
item_id: "li-3",
discount_id: "disc_999",
})
})
})
describe("update", () => {
const lineItemAdjustment = { id: "lia-1" }
const lineItemAdjustmentRepo = MockRepository({
findOne: (f) => Promise.resolve(lineItemAdjustment),
save: (f) => Promise.resolve(lineItemAdjustment),
})
const lineItemAdjustmentService = new LineItemAdjustmentService({
manager: MockManager,
lineItemAdjustmentRepository: lineItemAdjustmentRepo,
eventBusService: EventBusServiceMock,
})
beforeAll(async () => {
jest.clearAllMocks()
})
it("calls lineItemAdjustment uppdate method", async () => {
await lineItemAdjustmentService.update("lia-1", {
amount: 6000,
})
expect(lineItemAdjustmentRepo.save).toHaveBeenCalledTimes(1)
expect(lineItemAdjustmentRepo.save).toHaveBeenCalledWith({
...lineItemAdjustment,
})
})
})
describe("delete", () => {
beforeAll(async () => {
jest.clearAllMocks()
})
describe("delete by line item adjustment id", () => {
const lineItemAdjustment = { id: "lia-1", item_id: "li-1" }
const lineItemAdjustmentRepo = MockRepository({
find: (f) => Promise.resolve(lineItemAdjustment),
})
const lineItemAdjustmentService = new LineItemAdjustmentService({
manager: MockManager,
lineItemAdjustmentRepository: lineItemAdjustmentRepo,
eventBusService: EventBusServiceMock,
})
it("calls lineItemAdjustment delete method with the right params", async () => {
await lineItemAdjustmentService.delete("lia-1")
expect(lineItemAdjustmentRepo.find).toHaveBeenCalledTimes(1)
expect(lineItemAdjustmentRepo.find).toHaveBeenCalledWith({
where: {
id: "lia-1",
},
})
expect(lineItemAdjustmentRepo.remove).toHaveBeenCalledTimes(1)
expect(lineItemAdjustmentRepo.remove).toHaveBeenCalledWith(
lineItemAdjustment
)
})
})
describe("delete by item ids", () => {
const lineItemAdjustment = [
{ id: "lia-1", item: "li-1" },
{ id: "lia-2", item_id: "li-2" },
]
const lineItemAdjustmentRepo = MockRepository({
find: (f) => Promise.resolve(lineItemAdjustment),
})
const lineItemAdjustmentService = new LineItemAdjustmentService({
manager: MockManager,
lineItemAdjustmentRepository: lineItemAdjustmentRepo,
eventBusService: EventBusServiceMock,
})
it("calls lineItemAdjustment delete method with the right query", async () => {
const query = { item_id: ["li-1", "li-2", "li-3"] }
await lineItemAdjustmentService.delete(query)
expect(lineItemAdjustmentRepo.find).toHaveBeenCalledTimes(1)
expect(lineItemAdjustmentRepo.find).toHaveBeenCalledWith({
where: {
item_id: In(query.item_id),
},
})
expect(lineItemAdjustmentRepo.remove).toHaveBeenCalledTimes(1)
expect(lineItemAdjustmentRepo.remove).toHaveBeenCalledWith(
lineItemAdjustment
)
})
})
})
describe("createAdjustments", () => {
beforeEach(async () => {
jest.clearAllMocks()
})
const lineItemAdjustmentRepo = MockRepository({
find: (f) => Promise.resolve(lineItemAdjustment),
})
const lineItemAdjustmentService = new LineItemAdjustmentService({
manager: MockManager,
lineItemAdjustmentRepository: lineItemAdjustmentRepo,
discountService: DiscountServiceMock,
eventBusService: EventBusServiceMock,
})
lineItemAdjustmentService.createAdjustmentForLineItem = jest
.fn()
.mockImplementation(() => {
return Promise.resolve({
item_id: "li-1",
amount: 1000,
discount_id: "disc-1",
id: "lia-1",
description: "discount",
})
})
it("calls createAdjustmentForLineItem once when given a line item", () => {
const cart = {
id: "cart1",
discounts: ["disc-1"],
items: [{ id: "li-1" }],
},
lineItem = { id: "li-1" }
lineItemAdjustmentService.createAdjustments(cart, lineItem)
expect(
lineItemAdjustmentService.createAdjustmentForLineItem
).toHaveBeenCalledTimes(1)
expect(
lineItemAdjustmentService.createAdjustmentForLineItem
).toHaveBeenCalledWith(cart, lineItem)
})
it("calls createAdjustmentForLineItem 3 times when given a cart containing 3 line items", () => {
const cart = {
id: "cart1",
discounts: ["disc-1"],
items: [
{
id: "li-2",
},
{
id: "li-3",
},
{
id: "li-4",
},
],
}
lineItemAdjustmentService.createAdjustments(cart)
expect(
lineItemAdjustmentService.createAdjustmentForLineItem
).toHaveBeenCalledTimes(3)
expect(
lineItemAdjustmentService.createAdjustmentForLineItem
).toHaveBeenNthCalledWith(1, cart, { id: "li-2" })
})
})
})
+42 -21
View File
@@ -2,10 +2,11 @@ import paymentService from "medusa-interfaces/dist/payment-service"
import { IdMap, MockRepository, MockManager } from "medusa-test-utils"
import SwapService from "../swap"
import { InventoryServiceMock } from "../__mocks__/inventory"
import { LineItemAdjustmentServiceMock } from "../__mocks__/line-item-adjustment"
const eventBusService = {
emit: jest.fn(),
withTransaction: function () {
withTransaction: function() {
return this
},
}
@@ -166,7 +167,7 @@ describe("SwapService", () => {
Promise.resolve({ id: "cart", items: [{ id: "test-item" }] })
),
update: jest.fn().mockReturnValue(Promise.resolve()),
withTransaction: function () {
withTransaction: function() {
return this
},
}
@@ -178,7 +179,7 @@ describe("SwapService", () => {
const customShippingOptionService = {
create: jest.fn().mockReturnValue(Promise.resolve({ id: "cso-test" })),
update: jest.fn().mockReturnValue(Promise.resolve()),
withTransaction: function () {
withTransaction: function() {
return this
},
}
@@ -188,7 +189,7 @@ describe("SwapService", () => {
update: jest.fn().mockImplementation((d) => Promise.resolve(d)),
retrieve: () => Promise.resolve({}),
createReturnLines: jest.fn(() => Promise.resolve()),
withTransaction: function () {
withTransaction: function() {
return this
},
}
@@ -200,6 +201,7 @@ describe("SwapService", () => {
cartService,
lineItemService,
customShippingOptionService,
lineItemAdjustmentService: LineItemAdjustmentServiceMock,
})
it("finds swap and calls return create cart", async () => {
@@ -219,6 +221,7 @@ describe("SwapService", () => {
"order.claims",
"order.claims.additional_items",
"additional_items",
"additional_items.variant",
"return_order",
"return_order.items",
"return_order.shipping_method",
@@ -251,6 +254,24 @@ describe("SwapService", () => {
expect(cartService.create).toHaveBeenCalledTimes(1)
// expect(cartService.update).toHaveBeenCalledTimes(1)
expect(lineItemService.update).toHaveBeenCalledTimes(1)
expect(lineItemService.update).toHaveBeenCalledWith("test", {
cart_id: "cart",
})
expect(
LineItemAdjustmentServiceMock.createAdjustmentForLineItem
).toHaveBeenCalledTimes(1)
expect(
LineItemAdjustmentServiceMock.createAdjustmentForLineItem
).toHaveBeenCalledWith(
{ id: "cart" },
{
id: "test",
data: "lines",
}
)
expect(swapRepo.save).toHaveBeenCalledTimes(1)
expect(swapRepo.save).toHaveBeenCalledWith({
...existing,
@@ -330,7 +351,7 @@ describe("SwapService", () => {
const swapRepo = MockRepository()
const returnService = {
create: jest.fn().mockReturnValue(Promise.resolve({ id: "ret" })),
withTransaction: function () {
withTransaction: function() {
return this
},
}
@@ -431,7 +452,7 @@ describe("SwapService", () => {
{ items: [{ item_id: "1234", quantity: 2 }], data: "new" },
])
),
withTransaction: function () {
withTransaction: function() {
return this
},
}
@@ -452,7 +473,7 @@ describe("SwapService", () => {
const lineItemService = {
update: jest.fn(),
retrieve: () => Promise.resolve({}),
withTransaction: function () {
withTransaction: function() {
return this
},
}
@@ -541,7 +562,7 @@ describe("SwapService", () => {
})
}
}),
withTransaction: function () {
withTransaction: function() {
return this
},
}
@@ -595,14 +616,14 @@ describe("SwapService", () => {
data: "new",
})
}),
withTransaction: function () {
withTransaction: function() {
return this
},
}
const eventBusService = {
emit: jest.fn().mockReturnValue(Promise.resolve()),
withTransaction: function () {
withTransaction: function() {
return this
},
}
@@ -643,7 +664,7 @@ describe("SwapService", () => {
const lineItemService = {
update: jest.fn(),
retrieve: () => Promise.resolve({}),
withTransaction: function () {
withTransaction: function() {
return this
},
}
@@ -655,7 +676,7 @@ describe("SwapService", () => {
.mockReturnValue(
Promise.resolve({ id: "cart", items: [{ id: "test-item" }] })
),
withTransaction: function () {
withTransaction: function() {
return this
},
}
@@ -730,7 +751,7 @@ describe("SwapService", () => {
const eventBusService = {
emit: jest.fn().mockReturnValue(Promise.resolve()),
withTransaction: function () {
withTransaction: function() {
return this
},
}
@@ -745,7 +766,7 @@ describe("SwapService", () => {
updateShippingMethod: () => {
return Promise.resolve()
},
withTransaction: function () {
withTransaction: function() {
return this
},
}
@@ -760,7 +781,7 @@ describe("SwapService", () => {
update: () => {
return Promise.resolve()
},
withTransaction: function () {
withTransaction: function() {
return this
},
}
@@ -775,14 +796,14 @@ describe("SwapService", () => {
cancelPayment: jest.fn(() => {
return Promise.resolve()
}),
withTransaction: function () {
withTransaction: function() {
return this
},
}
const inventoryService = {
...InventoryServiceMock,
withTransaction: function () {
withTransaction: function() {
return this
},
}
@@ -904,7 +925,7 @@ describe("SwapService", () => {
describe("success", () => {
const eventBusService = {
emit: jest.fn().mockReturnValue(Promise.resolve()),
withTransaction: function () {
withTransaction: function() {
return this
},
}
@@ -916,7 +937,7 @@ describe("SwapService", () => {
refundPayment: jest.fn((g) =>
g[0].id === "good" ? Promise.resolve() : Promise.reject()
),
withTransaction: function () {
withTransaction: function() {
return this
},
}
@@ -1044,7 +1065,7 @@ describe("SwapService", () => {
const eventBusService = {
emit: jest.fn().mockReturnValue(Promise.resolve()),
withTransaction: function () {
withTransaction: function() {
return this
},
}
@@ -1107,7 +1128,7 @@ describe("SwapService", () => {
const paymentProviderService = {
cancelPayment: jest.fn(() => Promise.resolve({})),
withTransaction: function () {
withTransaction: function() {
return this
},
}
+209 -158
View File
@@ -55,136 +55,180 @@ const discounts = {
},
}
const applyDiscount = (cart, discount) => {
let newCart = { ...cart }
if (newCart.items) {
newCart.items = cart.items.map((item) => {
return {
...item,
adjustments: [
{
item_id: item.id,
amount: calculateAdjustment(newCart, item, discount),
description: "discount",
discount_id: discount.id,
},
],
}
})
}
return newCart
}
const calculateAdjustment = (cart, lineItem, discount) => {
let amount = discount.rule.value * lineItem.quantity
let lineItemPrice = lineItem.unit_price * lineItem.quantity
if (discount.rule.type === "fixed" && discount.rule.allocation === "total") {
let subtotal = cart.items.reduce(
(total, item) => total + item.unit_price * item.quantity,
0
)
const nominator = Math.min(discount.rule.value, subtotal)
amount = Math.round((lineItemPrice / subtotal) * nominator)
} else if (discount.rule.type === "percentage") {
amount = Math.round((lineItemPrice * discount.rule.value) / 100)
}
return amount > lineItemPrice ? lineItemPrice : amount
}
describe("TotalsService", () => {
const container = {
taxProviderService: {},
taxCalculationStrategy: {},
}
// TODO: Redo tests to include new line item adjustments
describe("getAllocationItemDiscounts", () => {
let res
// describe("getAllocationItemDiscounts", () => {
// let res
const totalsService = new TotalsService(container)
// const totalsService = new TotalsService(container)
beforeEach(() => {
jest.clearAllMocks()
})
// beforeEach(() => {
// jest.clearAllMocks()
// })
it("calculates item with percentage discount", async () => {
const cart = {
items: [
{
id: "test",
allow_discounts: true,
unit_price: 10,
quantity: 10,
variant: {
id: "testv",
product_id: "testp",
},
adjustments: [{ amount: 10 }],
},
],
}
// it("calculates item with percentage discount", async () => {
// const cart = {
// items: [
// {
// id: "test",
// allow_discounts: true,
// unit_price: 10,
// quantity: 10,
// variant: {
// id: "testv",
// product_id: "testp",
// },
// },
// ],
// }
const discount = {
rule: {
type: "percentage",
value: 10,
},
}
// const discount = {
// rule: {
// type: "percentage",
// value: 10,
// },
// }
res = totalsService.getAllocationItemDiscounts(discount, cart)
// res = totalsService.getAllocationItemDiscounts(discount, cart)
expect(res).toEqual([
{
lineItem: {
id: "test",
allow_discounts: true,
unit_price: 10,
quantity: 10,
variant: {
id: "testv",
product_id: "testp",
},
adjustments: [{ amount: 10 }],
},
variant: "testv",
amount: 10,
},
])
})
// expect(res).toEqual([
// {
// lineItem: {
// id: "test",
// allow_discounts: true,
// unit_price: 10,
// quantity: 10,
// variant: {
// id: "testv",
// product_id: "testp",
// },
// },
// variant: "testv",
// amount: 10,
// },
// ])
// })
it("calculates item with fixed discount", async () => {
const cart = {
items: [
{
id: "exists",
allow_discounts: true,
unit_price: 10,
variant: {
id: "testv",
product_id: "testp",
},
quantity: 10,
adjustments: [{ amount: 90 }],
},
],
}
// it("calculates item with fixed discount", async () => {
// const cart = {
// items: [
// {
// id: "exists",
// allow_discounts: true,
// unit_price: 10,
// variant: {
// id: "testv",
// product_id: "testp",
// },
// quantity: 10,
// },
// ],
// }
const discount = {
rule: {
type: "fixed",
value: 9,
// TODO: Add conditions relation
},
}
// const discount = {
// rule: {
// type: "fixed",
// value: 9,
// },
// }
res = totalsService.getAllocationItemDiscounts(discount, cart)
// res = totalsService.getAllocationItemDiscounts(discount, cart)
expect(res).toEqual([
{
lineItem: {
id: "exists",
allow_discounts: true,
unit_price: 10,
variant: {
id: "testv",
product_id: "testp",
},
quantity: 10,
adjustments: [{ amount: 90 }],
},
variant: "testv",
amount: 90,
},
])
})
// expect(res).toEqual([
// {
// lineItem: {
// id: "exists",
// allow_discounts: true,
// unit_price: 10,
// variant: {
// id: "testv",
// product_id: "testp",
// },
// quantity: 10,
// },
// variant: "testv",
// amount: 90,
// },
// ])
// })
// not relevant anymore
// it("does not apply discount if no valid variants are provided", async () => {
// const cart = {
// items: [
// {
// id: "exists",
// allow_discounts: true,
// unit_price: 10,
// variant: {
// id: "testv",
// product_id: "testp",
// },
// quantity: 10,
// },
// ],
// }
// it("does not apply discount if no valid variants are provided", async () => {
// const cart = {
// items: [
// {
// id: "exists",
// allow_discounts: true,
// unit_price: 10,
// variant: {
// id: "testv",
// product_id: "testp",
// },
// quantity: 10,
// },
// ],
// }
// const discount = {
// rule: {
// type: "fixed",
// value: 9,
// // TODO: Add conditions relation
// },
// }
// res = totalsService.getAllocationItemDiscounts(discount, cart)
// const discount = {
// rule: {
// type: "fixed",
// value: 9,
// },
// }
// res = totalsService.getAllocationItemDiscounts(discount, cart)
// expect(res).toEqual([])
// })
// })
// expect(res).toEqual([])
// })
})
describe("getDiscountTotal", () => {
let res
@@ -223,32 +267,36 @@ describe("TotalsService", () => {
discountCart.discounts = []
})
it("calculate total precentage discount", async () => {
it("calculate total percentage discount", async () => {
discountCart.discounts.push(discounts.total10Percent)
res = totalsService.getDiscountTotal(discountCart)
let cart = applyDiscount(discountCart, discounts.total10Percent)
res = totalsService.getDiscountTotal(cart)
expect(res).toEqual(28)
})
// TODO: Redo tests to include new line item adjustments
// it("calculate item fixed discount", async () => {
// discountCart.discounts.push(discounts.item2Fixed)
// res = totalsService.getDiscountTotal(discountCart)
it("calculate item fixed discount", async () => {
discountCart.discounts.push(discounts.item2Fixed)
let cart = applyDiscount(discountCart, discounts.item2Fixed)
res = totalsService.getDiscountTotal(cart)
// expect(res).toEqual(20)
// })
expect(res).toEqual(40)
})
// it("calculate item percentage discount", async () => {
// discountCart.discounts.push(discounts.item10Percent)
// res = totalsService.getDiscountTotal(discountCart)
it("calculate item percentage discount", async () => {
discountCart.discounts.push(discounts.item10Percent)
let cart = applyDiscount(discountCart, discounts.item10Percent)
res = totalsService.getDiscountTotal(cart)
// expect(res).toEqual(10)
// })
expect(res).toEqual(28)
})
it("calculate total fixed discount", async () => {
discountCart.discounts.push(discounts.total10Fixed)
res = totalsService.getDiscountTotal(discountCart)
let cart = applyDiscount(discountCart, discounts.total10Fixed)
res = totalsService.getDiscountTotal(cart)
expect(res).toEqual(10)
})
@@ -388,43 +436,45 @@ describe("TotalsService", () => {
// expect(res).toEqual(1244)
// })
// it("calculates refund with item fixed discount", async () => {
// orderToRefund.discounts.push(discounts.item2Fixed)
// res = totalsService.getRefundTotal(orderToRefund, [
// {
// id: "line2",
// unit_price: 100,
// allow_discounts: true,
// variant: {
// id: "variant",
// product_id: "testp2",
// },
// quantity: 10,
// returned_quantity: 0,
// },
// ])
it("calculates refund with item fixed discount", async () => {
orderToRefund.discounts.push(discounts.item2Fixed)
let order = applyDiscount(orderToRefund, discounts.item2Fixed)
res = totalsService.getRefundTotal(order, [
{
id: "line2",
unit_price: 100,
allow_discounts: true,
variant: {
id: "variant",
product_id: "testp2",
},
quantity: 10,
returned_quantity: 0,
},
])
// expect(res).toEqual(1225)
// })
expect(res).toEqual(1225)
})
// it("calculates refund with item percentage discount", async () => {
// orderToRefund.discounts.push(discounts.item10Percent)
// res = totalsService.getRefundTotal(orderToRefund, [
// {
// id: "line2",
// unit_price: 100,
// allow_discounts: true,
// variant: {
// id: "variant",
// product_id: "testp2",
// },
// quantity: 10,
// returned_quantity: 0,
// },
// ])
it("calculates refund with item percentage discount", async () => {
orderToRefund.discounts.push(discounts.item10Percent)
let order = applyDiscount(orderToRefund, discounts.item10Percent)
res = totalsService.getRefundTotal(order, [
{
id: "line2",
unit_price: 100,
allow_discounts: true,
variant: {
id: "variant",
product_id: "testp2",
},
quantity: 10,
returned_quantity: 0,
},
])
// expect(res).toEqual(1125)
// })
expect(res).toEqual(1125)
})
it("throws if line items to return is not in order", async () => {
const work = () =>
@@ -444,6 +494,7 @@ describe("TotalsService", () => {
expect(work).toThrow("Line item does not exist on order")
})
})
describe("getShippingTotal", () => {
let res
const totalsService = new TotalsService(container)
+83 -99
View File
@@ -21,12 +21,10 @@ import {
LineItemUpdate,
} from "../types/cart"
import { FindConfig, TotalField } from "../types/common"
import CustomShippingOptionService from "./custom-shipping-option"
import CustomerService from "./customer"
import DiscountService from "./discount"
import EventBusService from "./event-bus"
import GiftCardService from "./gift-card"
import InventoryService from "./inventory"
import LineItemService from "./line-item"
import PaymentProviderService from "./payment-provider"
import ProductService from "./product"
@@ -35,6 +33,9 @@ import RegionService from "./region"
import ShippingOptionService from "./shipping-option"
import TaxProviderService from "./tax-provider"
import TotalsService from "./totals"
import InventoryService from "./inventory"
import CustomShippingOptionService from "./custom-shipping-option"
import LineItemAdjustmentService from "./line-item-adjustment"
type CartConstructorProps = {
manager: EntityManager
@@ -56,6 +57,7 @@ type CartConstructorProps = {
totalsService: TotalsService
inventoryService: InventoryService
customShippingOptionService: CustomShippingOptionService
lineItemAdjustmentService: LineItemAdjustmentService
priceSelectionStrategy: IPriceSelectionStrategy
}
@@ -92,6 +94,7 @@ class CartService extends BaseService {
private paymentSessionRepository_: typeof PaymentSessionRepository
private inventoryService_: InventoryService
private customShippingOptionService_: CustomShippingOptionService
private lineItemAdjustmentService_: LineItemAdjustmentService
private priceSelectionStrategy_: IPriceSelectionStrategy
constructor({
@@ -114,6 +117,7 @@ class CartService extends BaseService {
paymentSessionRepository,
inventoryService,
customShippingOptionService,
lineItemAdjustmentService,
priceSelectionStrategy,
}: CartConstructorProps) {
super()
@@ -137,6 +141,7 @@ class CartService extends BaseService {
this.inventoryService_ = inventoryService
this.customShippingOptionService_ = customShippingOptionService
this.taxProviderService_ = taxProviderService
this.lineItemAdjustmentService_ = lineItemAdjustmentService
this.priceSelectionStrategy_ = priceSelectionStrategy
}
@@ -165,6 +170,7 @@ class CartService extends BaseService {
giftCardService: this.giftCardService_,
inventoryService: this.inventoryService_,
customShippingOptionService: this.customShippingOptionService_,
lineItemAdjustmentService: this.lineItemAdjustmentService_,
priceSelectionStrategy: this.priceSelectionStrategy_,
})
@@ -457,7 +463,12 @@ class CartService extends BaseService {
await this.lineItemService_.withTransaction(manager).delete(lineItem.id)
const result = await this.retrieve(cartId)
const result = await this.retrieve(cartId, {
relations: ["items", "discounts", "discounts.rule"],
})
await this.refreshAdjustments_(result)
// Notify subscribers
await this.eventBus_
.withTransaction(manager)
@@ -512,9 +523,12 @@ class CartService extends BaseService {
relations: [
"shipping_methods",
"items",
"items.adjustments",
"payment_sessions",
"items.variant",
"items.variant.product",
"discounts",
"discounts.rule",
],
})
@@ -570,10 +584,16 @@ class CartService extends BaseService {
}
}
const result = await this.retrieve(cartId)
const result = await this.retrieve(cartId, {
relations: ["items", "discounts", "discounts.rule"],
})
await this.refreshAdjustments_(result)
await this.eventBus_
.withTransaction(manager)
.emit(CartService.Events.UPDATED, result)
return result
})
}
@@ -592,7 +612,7 @@ class CartService extends BaseService {
): Promise<Cart> {
return this.atomicPhase_(async (manager: EntityManager) => {
const cart = await this.retrieve(cartId, {
relations: ["items", "payment_sessions"],
relations: ["items", "items.adjustments", "payment_sessions"],
})
// Ensure that the line item exists in the cart
@@ -621,8 +641,13 @@ class CartService extends BaseService {
.withTransaction(manager)
.update(lineItemId, lineItemUpdate)
const result = await this.retrieve(cartId, {
relations: ["items", "discounts", "discounts.rule"],
})
await this.refreshAdjustments_(result)
// Update the line item
const result = await this.retrieve(cartId)
await this.eventBus_
.withTransaction(manager)
.emit(CartService.Events.UPDATED, result)
@@ -1004,109 +1029,51 @@ class CartService extends BaseService {
* @return the result of the update operation
*/
async applyDiscount(cart: Cart, discountCode: string): Promise<void> {
const discount = await this.discountService_.retrieveByCode(discountCode, [
"rule",
"regions",
])
if (cart.customer_id) {
const canApply = await this.discountService_.canApplyForCustomer(
discount.rule.id,
cart.customer_id
return this.atomicPhase_(async (manager) => {
const discount = await this.discountService_.retrieveByCode(
discountCode,
["rule", "regions"]
)
if (!canApply) {
throw new MedusaError(
MedusaError.Types.NOT_ALLOWED,
"Discount is not valid for customer"
)
await this.discountService_.validateDiscountForCartOrThrow(cart, discount)
const rule = discount.rule
// if discount is already there, we simply resolve
if (cart.discounts.find(({ id }) => id === discount.id)) {
return Promise.resolve()
}
}
const rule = discount.rule
const toParse = [...cart.discounts, discount]
// if limit is set and reached, we make an early exit
if (discount.usage_limit) {
discount.usage_count = discount.usage_count || 0
if (discount.usage_count >= discount.usage_limit) {
throw new MedusaError(
MedusaError.Types.NOT_ALLOWED,
"Discount has been used maximum allowed times"
)
}
}
const today = new Date()
if (discount.starts_at > today) {
throw new MedusaError(
MedusaError.Types.NOT_ALLOWED,
"Discount is not valid yet"
)
}
if (discount.ends_at && discount.ends_at < today) {
throw new MedusaError(
MedusaError.Types.NOT_ALLOWED,
"Discount is expired"
)
}
let regions = discount.regions
if (discount.parent_discount_id) {
const parent = await this.discountService_.retrieve(
discount.parent_discount_id,
{
relations: ["rule", "regions"],
}
)
regions = parent.regions
}
if (discount.is_disabled) {
throw new MedusaError(
MedusaError.Types.NOT_ALLOWED,
"The discount code is disabled"
)
}
if (!regions.find(({ id }) => id === cart.region_id)) {
throw new MedusaError(
MedusaError.Types.INVALID_DATA,
"The discount is not available in current region"
)
}
// if discount is already there, we simply resolve
if (cart.discounts.find(({ id }) => id === discount.id)) {
return Promise.resolve()
}
const toParse = [...cart.discounts, discount]
let sawNotShipping = false
const newDiscounts = toParse.map((d) => {
const drule = d.rule
switch (drule.type) {
case "free_shipping":
if (d.rule.type === rule.type) {
return discount
}
return d
default:
if (!sawNotShipping) {
sawNotShipping = true
if (rule.type !== "free_shipping") {
let sawNotShipping = false
const newDiscounts = toParse.map((d) => {
const drule = d.rule
switch (drule.type) {
case "free_shipping":
if (d.rule.type === rule.type) {
return discount
}
return d
}
return null
default:
if (!sawNotShipping) {
sawNotShipping = true
if (rule.type !== "free_shipping") {
return discount
}
return d
}
return null
}
})
cart.discounts = newDiscounts.filter(Boolean) as Discount[]
// ignore if free shipping
if (rule.type !== "free_shipping" && cart?.items) {
await this.refreshAdjustments_(cart)
}
})
cart.discounts = newDiscounts.filter(Boolean) as Discount[]
}
/**
@@ -1888,6 +1855,23 @@ class CartService extends BaseService {
})
}
async refreshAdjustments_(cart: Cart): Promise<void> {
return this.atomicPhase_(async (manager: EntityManager) => {
const nonReturnLines = cart.items.filter((item) => !item.is_return)
const nonReturnLineIDs = nonReturnLines.map((i) => i.id)
// delete all old non return line item adjustments
await this.lineItemAdjustmentService_.withTransaction(manager).delete({
item_id: nonReturnLineIDs,
})
// potentially create/update line item adjustments
await this.lineItemAdjustmentService_
.withTransaction(manager)
.createAdjustments(cart)
})
}
/**
* Dedicated method to delete metadata for a cart.
* @param cartId - the cart to delete metadata from.
+94
View File
@@ -30,6 +30,7 @@ import {
UpdateDiscountInput,
UpsertDiscountConditionInput,
} from "../types/discount"
import { isFuture, isPast } from "../utils/date-helpers"
import { formatException, PostgresError } from "../utils/exception-formatter"
/**
@@ -735,6 +736,99 @@ class DiscountService extends BaseService {
return adjustment >= fullItemPrice ? fullItemPrice : adjustment
}
async validateDiscountForCartOrThrow(
cart: Cart,
discount: Discount
): Promise<void> {
if (this.hasReachedLimit(discount)) {
throw new MedusaError(
MedusaError.Types.NOT_ALLOWED,
"Discount has been used maximum allowed times"
)
}
if (this.hasNotStarted(discount)) {
throw new MedusaError(
MedusaError.Types.NOT_ALLOWED,
"Discount is not valid yet"
)
}
if (this.hasExpired(discount)) {
throw new MedusaError(
MedusaError.Types.NOT_ALLOWED,
"Discount is expired"
)
}
if (this.isDisabled(discount)) {
throw new MedusaError(
MedusaError.Types.NOT_ALLOWED,
"The discount code is disabled"
)
}
const isValidForRegion = await this.isValidForRegion(
discount,
cart.region_id
)
if (!isValidForRegion) {
throw new MedusaError(
MedusaError.Types.INVALID_DATA,
"The discount is not available in current region"
)
}
if (cart.customer_id) {
const canApplyForCustomer = await this.canApplyForCustomer(
discount.rule.id,
cart.customer_id
)
if (!canApplyForCustomer) {
throw new MedusaError(
MedusaError.Types.NOT_ALLOWED,
"Discount is not valid for customer"
)
}
}
}
hasReachedLimit(discount: Discount): boolean {
const count = discount.usage_count || 0
const limit = discount.usage_limit
return !!limit && count >= limit
}
hasNotStarted(discount: Discount): boolean {
return isFuture(discount.starts_at)
}
hasExpired(discount: Discount): boolean {
return discount.ends_at && isPast(discount.ends_at)
}
isDisabled(discount: Discount): boolean {
return discount.is_disabled
}
async isValidForRegion(
discount: Discount,
region_id: string
): Promise<boolean> {
let regions = discount.regions
if (discount.parent_discount_id) {
const parent = await this.retrieve(discount.parent_discount_id, {
relations: ["rule", "regions"],
})
regions = parent.regions
}
return regions.find(({ id }) => id === region_id) !== undefined
}
async canApplyForCustomer(
discountRuleId: string,
customerId: string | undefined
@@ -290,6 +290,7 @@ class DraftOrderService extends BaseService {
.generate(item.variant_id, data.region_id, item.quantity, {
metadata: item?.metadata || {},
unit_price: item.unit_price,
cart: createdCart,
})
await this.lineItemService_.withTransaction(manager).create({
@@ -0,0 +1,303 @@
import { EntityManager } from "typeorm"
import { BaseService } from "medusa-interfaces"
import { MedusaError } from "medusa-core-utils"
import { LineItemAdjustmentRepository } from "../repositories/line-item-adjustment"
import { FindConfig } from "../types/common"
import { LineItemAdjustment } from "../models/line-item-adjustment"
import { FilterableLineItemAdjustmentProps } from "../types/line-item-adjustment"
import { LineItem } from "../models/line-item"
import { Cart } from "../models/cart"
import DiscountService from "./discount"
import { ProductVariant } from "../models/product-variant"
type LineItemAdjustmentServiceProps = {
manager: EntityManager
lineItemAdjustmentRepository: typeof LineItemAdjustmentRepository
discountService: DiscountService
}
type AdjustmentContext = {
variant: ProductVariant
}
type GeneratedAdjustment = Omit<LineItem, "id" | "item_id">
/**
* Provides layer to manipulate line item adjustments.
* @extends BaseService
*/
class LineItemAdjustmentService extends BaseService {
private manager_: EntityManager
private lineItemAdjustmentRepo_: typeof LineItemAdjustmentRepository
private discountService: DiscountService
constructor({
manager,
lineItemAdjustmentRepository,
discountService,
}: LineItemAdjustmentServiceProps) {
super()
this.manager_ = manager
this.lineItemAdjustmentRepo_ = lineItemAdjustmentRepository
this.discountService = discountService
}
withTransaction(
transactionManager: EntityManager
): LineItemAdjustmentService {
if (!transactionManager) {
return this
}
const cloned = new LineItemAdjustmentService({
manager: transactionManager,
lineItemAdjustmentRepository: this.lineItemAdjustmentRepo_,
discountService: this.discountService,
})
cloned.transactionManager_ = transactionManager
return cloned
}
/**
* Retrieves a line item adjustment by id.
* @param id - the id of the line item adjustment to retrieve
* @param config - the config to retrieve the line item adjustment by
* @return the line item adjustment.
*/
async retrieve(
id: string,
config: FindConfig<LineItemAdjustment> = {}
): Promise<LineItemAdjustment> {
const lineItemAdjustmentRepo: LineItemAdjustmentRepository =
this.manager_.getCustomRepository(this.lineItemAdjustmentRepo_)
const query = this.buildQuery_({ id }, config)
const lineItemAdjustment = await lineItemAdjustmentRepo.findOne(query)
if (!lineItemAdjustment) {
throw new MedusaError(
MedusaError.Types.NOT_FOUND,
`Line item adjustment with id: ${id} was not found`
)
}
return lineItemAdjustment
}
/**
* Creates a line item adjustment
* @param data - the line item adjustment to create
* @return line item adjustment
*/
async create(data: Partial<LineItemAdjustment>): Promise<LineItemAdjustment> {
return await this.atomicPhase_(async (manager: EntityManager) => {
const lineItemAdjustmentRepo: LineItemAdjustmentRepository =
manager.getCustomRepository(this.lineItemAdjustmentRepo_)
const lineItemAdjustment = await lineItemAdjustmentRepo.create(data)
return await lineItemAdjustmentRepo.save(lineItemAdjustment)
})
}
/**
* Creates a line item adjustment
* @param id - the line item adjustment id to update
* @param data - the line item adjustment to create
* @return line item adjustment
*/
async update(
id: string,
data: Partial<LineItemAdjustment>
): Promise<LineItemAdjustment> {
return await this.atomicPhase_(async (manager: EntityManager) => {
const lineItemAdjustmentRepo: LineItemAdjustmentRepository =
manager.getCustomRepository(this.lineItemAdjustmentRepo_)
const lineItemAdjustment = await this.retrieve(id)
const { metadata, ...rest } = data
if (metadata) {
lineItemAdjustment.metadata = this.setMetadata_(
lineItemAdjustment,
metadata
)
}
for (const [key, value] of Object.entries(rest)) {
lineItemAdjustment[key] = value
}
const result = await lineItemAdjustmentRepo.save(lineItemAdjustment)
return result
})
}
/**
* Lists line item adjustments
* @param selector - the query object for find
* @param config - the config to be used for find
* @return the result of the find operation
*/
async list(
selector: FilterableLineItemAdjustmentProps = {},
config: FindConfig<LineItemAdjustment> = { skip: 0, take: 20 }
): Promise<LineItemAdjustment[]> {
const lineItemAdjustmentRepo = this.manager_.getCustomRepository(
this.lineItemAdjustmentRepo_
)
const query = this.buildQuery_(selector, config)
return await lineItemAdjustmentRepo.find(query)
}
/**
* Deletes line item adjustments matching a selector
* @param selectorOrId - the query object for find or the line item adjustment id
* @return the result of the delete operation
*/
async delete(
selectorOrId: string | FilterableLineItemAdjustmentProps
): Promise<void> {
return this.atomicPhase_(async (manager) => {
const lineItemAdjustmentRepo: LineItemAdjustmentRepository =
manager.getCustomRepository(this.lineItemAdjustmentRepo_)
if (typeof selectorOrId === "string") {
return await this.delete({ id: selectorOrId })
}
const query = this.buildQuery_(selectorOrId)
const lineItemAdjustments = await lineItemAdjustmentRepo.find(query)
await lineItemAdjustmentRepo.remove(lineItemAdjustments)
return Promise.resolve()
})
}
/**
* Creates adjustment for a line item
* @param cart - the cart object holding discounts
* @param generatedLineItem - the line item for which a line item adjustment might be created
* @param context - the line item for which a line item adjustment might be created
* @return a line item adjustment or undefined if no adjustment was created
*/
async generateAdjustments(
cart: Cart,
generatedLineItem: LineItem,
context: AdjustmentContext
): Promise<GeneratedAdjustment[]> {
return this.atomicPhase_(async (manager) => {
// if lineItem should not be discounted
// or lineItem is a return line item
// or the cart does not have any discounts
// then do nothing
if (
!generatedLineItem.allow_discounts ||
generatedLineItem.is_return ||
!cart?.discounts?.length
) {
return []
}
const [discount] = cart.discounts.filter(
(d) => d.rule.type !== "free_shipping"
)
// if no discount is applied to the cart then return
if (!discount) {
return []
}
const lineItemProduct = context.variant.product_id
const isValid = await this.discountService
.withTransaction(manager)
.validateDiscountForProduct(discount.rule_id, lineItemProduct)
// if discount is not valid for line item, then do nothing
if (!isValid) {
return []
}
const amount = await this.discountService.calculateDiscountForLineItem(
discount.id,
generatedLineItem,
cart
)
// if discounted amount is 0, then do nothing
if (amount === 0) {
return []
}
const adjustments = [
{
amount,
discount_id: discount.id,
description: "discount",
},
]
return adjustments
})
}
/**
* Creates adjustment for a line item
* @param cart - the cart object holding discounts
* @param lineItem - the line item for which a line item adjustment might be created
* @return a line item adjustment or undefined if no adjustment was created
*/
async createAdjustmentForLineItem(
cart: Cart,
lineItem: LineItem
): Promise<LineItemAdjustment[]> {
const adjustments = await this.generateAdjustments(cart, lineItem, {
variant: lineItem.variant,
})
const createdAdjustments: LineItemAdjustment[] = []
for (const adjustment of adjustments) {
const created = await this.create({
item_id: lineItem.id,
...adjustment,
})
createdAdjustments.push(created)
}
return createdAdjustments
}
/**
* Creates adjustment for a line item
* @param cart - the cart object holding discounts
* @param lineItem - the line item for which a line item adjustment might be created
* @return if a lineItem was given, returns a line item adjustment or undefined if no adjustment was created
* otherwise returns an array of line item adjustments for each line item in the cart
*/
async createAdjustments(
cart: Cart,
lineItem?: LineItem
): Promise<LineItemAdjustment[] | LineItemAdjustment[][]> {
if (lineItem) {
return await this.createAdjustmentForLineItem(cart, lineItem)
}
if (!cart.items) {
return []
}
return await Promise.all(
cart.items.map((li) => this.createAdjustmentForLineItem(cart, li))
)
}
}
export default LineItemAdjustmentService
+25 -6
View File
@@ -14,6 +14,7 @@ class LineItemService extends BaseService {
productService,
regionService,
cartRepository,
lineItemAdjustmentService,
}) {
super()
@@ -37,6 +38,8 @@ class LineItemService extends BaseService {
/** @private @const {CartRepository} */
this.cartRepository_ = cartRepository
this.lineItemAdjustmentService_ = lineItemAdjustmentService
}
withTransaction(transactionManager) {
@@ -52,6 +55,7 @@ class LineItemService extends BaseService {
productService: this.productService_,
regionService: this.regionService_,
cartRepository: this.cartRepository_,
lineItemAdjustmentService: this.lineItemAdjustmentService_,
})
cloned.transactionManager_ = transactionManager
@@ -131,13 +135,21 @@ class LineItemService extends BaseService {
})
}),
metadata: i.metadata,
adjustments: i.adjustments.map((adjustment) => {
return {
amount: -1 * adjustment.amount,
description: adjustment.description,
discount_id: adjustment.discount_id,
metadata: adjustment.metadata,
}
}),
})
)
return await lineItemRepo.save(toCreate)
}
async generate(variantId, regionId, quantity, config = {}) {
async generate(variantId, regionId, quantity, context = {}) {
return this.atomicPhase_(async (manager) => {
const variant = await this.productVariantService_
.withTransaction(manager)
@@ -153,14 +165,14 @@ class LineItemService extends BaseService {
let price
let shouldMerge = true
if (config.unit_price !== undefined && config.unit_price !== null) {
if (context.unit_price !== undefined && context.unit_price !== null) {
// if custom unit_price, we ensure positive values
// and we choose to not merge the items
shouldMerge = false
if (config.unit_price < 0) {
if (context.unit_price < 0) {
price = 0
} else {
price = config.unit_price
price = context.unit_price
}
} else {
price = await this.productVariantService_
@@ -168,7 +180,7 @@ class LineItemService extends BaseService {
.getRegionPrice(variant.id, {
regionId: region.id,
quantity: quantity,
customer_id: config.customer_id,
customer_id: context.customer_id,
include_discount_prices: true,
})
}
@@ -182,10 +194,17 @@ class LineItemService extends BaseService {
quantity: quantity || 1,
allow_discounts: variant.product.discountable,
is_giftcard: variant.product.is_giftcard,
metadata: config?.metadata || {},
metadata: context?.metadata || {},
should_merge: shouldMerge,
}
if (context.cart) {
const adjustments = await this.lineItemAdjustmentService_
.withTransaction(manager)
.generateAdjustments(context.cart, toCreate, { variant })
toCreate.adjustments = adjustments
}
return toCreate
})
}
+3
View File
@@ -298,12 +298,15 @@ class OrderService extends BaseService {
const relationSet = new Set(relations)
relationSet.add("items")
relationSet.add("items.tax_lines")
relationSet.add("items.adjustments")
relationSet.add("swaps")
relationSet.add("swaps.additional_items")
relationSet.add("swaps.additional_items.tax_lines")
relationSet.add("swaps.additional_items.adjustments")
relationSet.add("claims")
relationSet.add("claims.additional_items")
relationSet.add("claims.additional_items.tax_lines")
relationSet.add("claims.additional_items.adjustments")
relationSet.add("discounts")
relationSet.add("discounts.rule")
relationSet.add("gift_cards")
+10
View File
@@ -33,6 +33,7 @@ class SwapService extends BaseService {
orderService,
inventoryService,
customShippingOptionService,
lineItemAdjustmentService,
}) {
super()
@@ -77,6 +78,9 @@ class SwapService extends BaseService {
/** @private @const {CustomShippingOptionService} */
this.customShippingOptionService_ = customShippingOptionService
/** @private @const {LineItemAdjustmentService} */
this.lineItemAdjustmentService_ = lineItemAdjustmentService
}
withTransaction(transactionManager) {
@@ -99,6 +103,7 @@ class SwapService extends BaseService {
inventoryService: this.inventoryService_,
fulfillmentService: this.fulfillmentService_,
customShippingOptionService: this.customShippingOptionService_,
lineItemAdjustmentService: this.lineItemAdjustmentService_,
})
cloned.transactionManager_ = transactionManager
@@ -549,6 +554,7 @@ class SwapService extends BaseService {
"order.claims",
"order.claims.additional_items",
"additional_items",
"additional_items.variant",
"return_order",
"return_order.items",
"return_order.shipping_method",
@@ -605,6 +611,10 @@ class SwapService extends BaseService {
await this.lineItemService_.withTransaction(manager).update(item.id, {
cart_id: cart.id,
})
// we generate adjustments in case the cart has any discounts that should be applied to the additional items
await this.lineItemAdjustmentService_
.withTransaction(manager)
.createAdjustmentForLineItem(cart, item)
}
// If the swap has a return shipping method the price has to be added to
+55 -65
View File
@@ -200,8 +200,6 @@ class TotalsService extends BaseService {
calculationContext
)
console.log(orderLines)
taxLines = orderLines.filter((ol) => {
if ("shipping_method_id" in ol) {
return ol.shipping_method_id === shippingMethod.id
@@ -465,6 +463,7 @@ class TotalsService extends BaseService {
const discountAmount =
(allocationMap[lineItem.id]?.discount?.unit_amount || 0) *
lineItem.quantity
const lineSubtotal =
lineItem.unit_price * lineItem.quantity - discountAmount
@@ -589,11 +588,46 @@ class TotalsService extends BaseService {
discount: Discount,
cart: Cart | Order
): LineDiscount[] {
const discounts: LineDiscount[] = []
// TODO: Add line item adjustments
const discounts: LineDiscount[] = cart.items.map((item) => ({
lineItem: item,
variant: item.variant.id,
amount: this.getLineItemDiscountAdjustment(item, discount),
}))
return discounts
}
getLineItemDiscountAdjustment(
lineItem: LineItem,
discount: Discount
): number {
const matchingDiscount = lineItem.adjustments.find(
(adjustment) => adjustment.discount_id === discount.id
)
if (!matchingDiscount) {
return 0
}
return matchingDiscount.amount
}
getLineItemAdjustmentsTotal(cartOrOrder: Cart | Order): number {
if (!cartOrOrder?.items?.length) {
return 0
}
return cartOrOrder.items.reduce(
(total, item) =>
total +
item.adjustments?.reduce(
(total, adjustment) => total + adjustment.amount,
0
) || 0,
0
)
}
/**
* Returns the discount amount allocated to the line items of an order.
* @param cartOrOrder - the cart or order to get line discount allocations for
@@ -605,10 +639,6 @@ class TotalsService extends BaseService {
cartOrOrder: Cart | Order,
discount: Discount
): LineDiscountAmount[] {
const subtotal = this.getSubtotal(cartOrOrder, {
excludeNonDiscounts: true,
})
let merged: LineItem[] = [...cartOrOrder.items]
// merge items from order with items from order swaps
@@ -624,43 +654,22 @@ class TotalsService extends BaseService {
}
}
const { type, allocation, value } = discount.rule
if (allocation === "total") {
let percentage = 0
if (type === "percentage") {
percentage = value / 100
} else if (type === "fixed") {
// If the fixed discount exceeds the subtotal we should
// calculate a 100% discount
const nominator = Math.min(value, subtotal)
percentage = nominator / subtotal
}
return merged.map((item) => {
const lineTotal = item.unit_price * item.quantity
return {
item,
amount: item.allow_discounts ? lineTotal * percentage : 0,
}
})
} else if (allocation === "item") {
const allocationDiscounts = this.getAllocationItemDiscounts(
discount,
cartOrOrder
return merged.map((item) => {
const adjustments = item?.adjustments || []
const discountAdjustments = adjustments.filter(
(adjustment) => adjustment.discount_id === discount.id
)
return merged.map((item) => {
const discounted = allocationDiscounts.find(
(a) => a.lineItem.id === item.id
)
return {
item,
amount: discounted ? discounted.amount : 0,
}
})
}
return merged.map((i) => ({ item: i, amount: 0 }))
return {
item,
amount: item.allow_discounts
? discountAdjustments.reduce(
(total, adjustment) => total + adjustment.amount,
0
)
: 0,
}
})
}
/**
@@ -865,32 +874,13 @@ class TotalsService extends BaseService {
return 0
}
const { type, allocation, value } = discount.rule
let toReturn = 0
if (type === "percentage" && allocation === "total") {
toReturn = (subtotal / 100) * value
} else if (type === "percentage" && allocation === "item") {
const itemPercentageDiscounts = this.getAllocationItemDiscounts(
discount,
cartOrOrder
)
toReturn = _.sumBy(itemPercentageDiscounts, (d) => d.amount)
} else if (type === "fixed" && allocation === "total") {
toReturn = value
} else if (type === "fixed" && allocation === "item") {
const itemFixedDiscounts = this.getAllocationItemDiscounts(
discount,
cartOrOrder
)
toReturn = _.sumBy(itemFixedDiscounts, (d) => d.amount)
}
const discountTotal = this.getLineItemAdjustmentsTotal(cartOrOrder)
if (subtotal < 0) {
return this.rounded(Math.max(subtotal, toReturn))
return this.rounded(Math.max(subtotal, discountTotal))
}
return this.rounded(Math.min(subtotal, toReturn))
return this.rounded(Math.min(subtotal, discountTotal))
}
/**
@@ -0,0 +1,27 @@
import { ValidateNested } from "class-validator"
import { IsType } from "../utils/validators/is-type"
import { DateComparisonOperator, StringComparisonOperator } from "./common"
export class FilterableLineItemAdjustmentProps {
@ValidateNested()
@IsType([String, [String], StringComparisonOperator])
id?: string | string[] | StringComparisonOperator
@ValidateNested()
@IsType([String, [String]])
item_id?: string | string[]
@ValidateNested()
@IsType([String, [String]])
description?: string | string[]
@ValidateNested()
@IsType([String, [String]])
resource_id?: string | string[]
@IsType([DateComparisonOperator])
created_at?: DateComparisonOperator
@IsType([DateComparisonOperator])
updated_at?: DateComparisonOperator
}
+11
View File
@@ -0,0 +1,11 @@
import { isDate } from "lodash"
export const isPast = (date: Date | null) => {
const now = new Date()
return isDate(date) && now > date
}
export const isFuture = (date: Date | null) => {
const now = new Date()
return isDate(date) && date > now
}