feat(medusa): Tax-inclusive pricing (#2131)

* add feature flag for tax inclusive pricing

* update db model for TIP

* add migration

* set featureflag column decorators

* remove unused prop

* update tests to reflect feature_flags as any array

* fix types

* reference key from featureFlag file

* use feature flag key in models

* fix copy paste mistake

* unify spelling

* Create gorgeous-experts-guess.md

* feat(medusa): create/update endpoints of currency/region/price-lists/shipping-options should allow to pass includes_tax

* test(integration): continue to add some integration test

* test(integration): continue to add some integration test

* test(unit): Fix region service tests

* fix(medusa): API unit tests flags management

* feat(medusa): Minor cleanup

* style(medusa): Fix typo

* fix(medusa): rebase

* feat(medusa): Replace old tag with the new one

* feat(medusa): revert flag

* feat(medusa): Cleanup

* feat(medusa): feedback

* feat(medusa): Rename currency retrieve method

* test(medudsa): fix unit tests

* chore(medusa): fix oas

* feat(medusa): ShippingMethod should include tax setting from parent option (#2021)

* feat(medusa): Shipping method should includes tax from parent options

* feat(medusa): Condition the includes tax flag to the availability of the feature and add some other tests

* test(integration): Move cart/order ff test in separate files

* fix: snapshots folder

* fix(integration): snapshots

* Create calm-baboons-sit.md

* test(integration): file naming

Co-authored-by: Carlos R. L. Rodrigues <rodrigolr@gmail.com>

* Feat/tax inclusive pricing extend price selection strategy (#2087)

* initial changes to price selection strategy including unit tests

* typing for tax calculation

* update types and remove region and currency from prices results

* fix casing

* include tax calculation in priceselectionstrategy

* integration tests for tax inclusive pricing price calculations

* fix build

* include tax inclusive considerations when calculating tax fields for variants

* include only "includes_tax" fields from currency and region joins

* test to see errors in pipelines

* conditionally join featureflagged fields

* add "includes_tax" to price list factory

* add tests for tax inclusive price list prices and currency prices

* fix unit tests

* refactor pricing array checks to expect arraycontaining

* undo error handler

* Feat/tax inclusive pricing flag on generated lineitems (#2108)

* include tax inclusive pricing flag on generated lineitems

* initial addition of tax inclusivity for lineitem service

* add generate test to ensure that includes_tax is set when returned from price selection strategy

* add integration test for generating lineitem including tax

* add test for negative tax inclusion

* add tests for mixed pricing

* add negative test for setting tax exclusivity

* restructure the setting of includes_tax on lineitems

* fix: update cwd to be correct in cart test

* feat(medusa): Line item totals calculations (#2123)

* feat(medusa): Update totals and tax calculation way to calculate the totals

* feat(medusa): remove region feetching from decorate total

* feat(medusa): cleanup

* test(medusa): fix tax calculation tests

* comment

* test(integration): cleanup

* test(integration): cleanup

* fix(medusa): return service missing await

* feat(medusa): cleanup

* feat(medusa): cleanup

* test(integration): fix data

* feat(medusa): improve tax calculation readability

* test(medusa): improve tax calculation structure case

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

* Feat(medusa): tax inclusive pricing in shipping method tax (#2125)

* initial implementation and test

* include tax inclusive calculations for getting shipping options

* remove inaccurate comment

* remove console log

* refactor how prices and taxes are set for shipping methods

* fix integration tests

* remove verbose flag

* fix integration tests

* remove console log

* format util

* use util in price service and tax strategy

* fix faulty integration test

* undo tax calculation strategy changes in favor or Carlos' pr

* undo changes to tax calculation strategy tests

* round tax amount

* feat(medusa): cleanup calculate tax amount utils and its usage (#2136)

* feat(medusa): Refund line totals calculation (#2139)

Rely on the update of the following pr https://github.com/medusajs/medusa/pull/2136

**WIP Missing integration tests**

**What**

Update the totals calculation on the refund line to include the notion of tax inclusive

**Test**

- Update and add new tests around the refund


Fixes CORE-482

* feat(medusa): Tax inclusive discounts calculations (#2137)

**What**

- Calculate line adjustments correctly taking into account the tax inclusivity
- fix totals getLineItemTotals by adjusting the sub total with the original tax amount instead of the tax amount when the unit price includes the taxes

**Tests**
- The tests create a cart with a percentage discount of 15%, the cart includes 2 items mixing the tax inclusive and validate the items on the result cart as well as the totals on each item. I ve based my calculation validation based on what we have done + some articles around discount apply on price without taxes to validate the output.,
FIXES CORE-477

* Chore: shipping methods tax inclusive total (#2130)

* chore: calculate tax inclusive shipping methods

* chore: additional tests and check undefined tax_rate (#2157)

* chore: additional tests and check undefined tax_rate

* fix: naming + correct price type check

* fix: remove price_includes_tax from type

* fix: remove price_includes_tax from type

Co-authored-by: Philip Korsholm <philip.korsholm@hotmail.com>
Co-authored-by: adrien2p <adrien.deperetti@gmail.com>
Co-authored-by: Carlos R. L. Rodrigues <rodrigolr@gmail.com>
Co-authored-by: Philip Korsholm <88927411+pKorsholm@users.noreply.github.com>
Co-authored-by: Sebastian Rindom <skrindom@gmail.com>
Co-authored-by: Carlos R. L. Rodrigues <37986729+carlos-r-l-rodrigues@users.noreply.github.com>
This commit is contained in:
105 files changed
+6493 -745

No files matched your search

+5
View File
@@ -0,0 +1,5 @@
---
"@medusajs/medusa": patch
---
Pass down the includes_tax to the shipping method from the shipping option
+5
View File
@@ -0,0 +1,5 @@
---
"@medusajs/medusa": patch
---
Extend models Currency, Region, PriceList, ShippingOption, LineItem, ShippingMethod with tax inclusive flag
@@ -0,0 +1,163 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`/admin/currencies GET /admin/currencies should retrieve the currencies 1`] = `
Object {
"count": 120,
"currencies": Array [
Object {
"code": "aed",
"includes_tax": false,
"name": "United Arab Emirates Dirham",
"symbol": "AED",
"symbol_native": "د.إ.‏",
},
Object {
"code": "afn",
"includes_tax": false,
"name": "Afghan Afghani",
"symbol": "Af",
"symbol_native": "؋",
},
Object {
"code": "all",
"includes_tax": false,
"name": "Albanian Lek",
"symbol": "ALL",
"symbol_native": "Lek",
},
Object {
"code": "amd",
"includes_tax": false,
"name": "Armenian Dram",
"symbol": "AMD",
"symbol_native": "դր.",
},
Object {
"code": "ars",
"includes_tax": false,
"name": "Argentine Peso",
"symbol": "AR$",
"symbol_native": "$",
},
Object {
"code": "aud",
"includes_tax": false,
"name": "Australian Dollar",
"symbol": "AU$",
"symbol_native": "$",
},
Object {
"code": "azn",
"includes_tax": false,
"name": "Azerbaijani Manat",
"symbol": "man.",
"symbol_native": "ман.",
},
Object {
"code": "bam",
"includes_tax": false,
"name": "Bosnia-Herzegovina Convertible Mark",
"symbol": "KM",
"symbol_native": "KM",
},
Object {
"code": "bdt",
"includes_tax": false,
"name": "Bangladeshi Taka",
"symbol": "Tk",
"symbol_native": "৳",
},
Object {
"code": "bgn",
"includes_tax": false,
"name": "Bulgarian Lev",
"symbol": "BGN",
"symbol_native": "лв.",
},
Object {
"code": "bhd",
"includes_tax": false,
"name": "Bahraini Dinar",
"symbol": "BD",
"symbol_native": "د.ب.‏",
},
Object {
"code": "bif",
"includes_tax": false,
"name": "Burundian Franc",
"symbol": "FBu",
"symbol_native": "FBu",
},
Object {
"code": "bnd",
"includes_tax": false,
"name": "Brunei Dollar",
"symbol": "BN$",
"symbol_native": "$",
},
Object {
"code": "bob",
"includes_tax": false,
"name": "Bolivian Boliviano",
"symbol": "Bs",
"symbol_native": "Bs",
},
Object {
"code": "brl",
"includes_tax": false,
"name": "Brazilian Real",
"symbol": "R$",
"symbol_native": "R$",
},
Object {
"code": "bwp",
"includes_tax": false,
"name": "Botswanan Pula",
"symbol": "BWP",
"symbol_native": "P",
},
Object {
"code": "byn",
"includes_tax": false,
"name": "Belarusian Ruble",
"symbol": "Br",
"symbol_native": "руб.",
},
Object {
"code": "bzd",
"includes_tax": false,
"name": "Belize Dollar",
"symbol": "BZ$",
"symbol_native": "$",
},
Object {
"code": "cad",
"includes_tax": false,
"name": "Canadian Dollar",
"symbol": "CA$",
"symbol_native": "$",
},
Object {
"code": "cdf",
"includes_tax": false,
"name": "Congolese Franc",
"symbol": "CDF",
"symbol_native": "FrCD",
},
],
"limit": 20,
"offset": 0,
}
`;
exports[`/admin/currencies POST /admin/currencies/:code should update currency includes_tax 1`] = `
Object {
"currency": Object {
"code": "aed",
"includes_tax": true,
"name": "United Arab Emirates Dirham",
"symbol": "AED",
"symbol_native": "د.إ.‏",
},
}
`;
@@ -121,12 +121,7 @@ Object {
"symbol_native": "$", "symbol_native": "$",
}, },
"default_currency_code": "usd", "default_currency_code": "usd",
"feature_flags": Array [ "feature_flags": Any<Array>,
Object {
"key": "sales_channels",
"value": false,
},
],
"fulfillment_providers": Array [ "fulfillment_providers": Array [
Object { Object {
"id": "test-ful", "id": "test-ful",
@@ -0,0 +1,89 @@
const path = require("path")
const startServerWithEnvironment =
require("../../../helpers/start-server-with-environment").default
const { useApi } = require("../../../helpers/use-api")
const { useDb } = require("../../../helpers/use-db")
const adminSeeder = require("../../helpers/admin-seeder");
const adminReqConfig = {
headers: {
Authorization: "Bearer test_token",
},
}
jest.setTimeout(30000)
describe("/admin/currencies", () => {
let medusaProcess
let dbConnection
beforeAll(async () => {
const cwd = path.resolve(path.join(__dirname, "..", ".."))
const [process, connection] = await startServerWithEnvironment({
cwd,
env: { MEDUSA_FF_TAX_INCLUSIVE_PRICING: true },
verbose: false,
})
dbConnection = connection
medusaProcess = process
})
afterAll(async () => {
const db = useDb()
await db.shutdown()
medusaProcess.kill()
})
describe("GET /admin/currencies", function () {
beforeEach(async () => {
try {
await adminSeeder(dbConnection)
} catch (e) {
console.error(e)
}
})
afterEach(async () => {
const db = useDb()
await db.teardown()
})
it("should retrieve the currencies", async () => {
const api = useApi()
const response = await api.get(
`/admin/currencies?order=code`,
adminReqConfig
)
expect(response.data).toMatchSnapshot()
})
});
describe("POST /admin/currencies/:code", function () {
beforeEach(async () => {
try {
await adminSeeder(dbConnection)
} catch (e) {
console.error(e)
}
})
afterEach(async () => {
const db = useDb()
await db.teardown()
})
it("should update currency includes_tax", async () => {
const api = useApi()
const response = await api.post(
`/admin/currencies/aed`,
{
includes_tax: true
},
adminReqConfig
)
expect(response.data).toMatchSnapshot()
})
});
})
@@ -0,0 +1,101 @@
const path = require("path")
const startServerWithEnvironment =
require("../../../../helpers/start-server-with-environment").default
const { useApi } = require("../../../../helpers/use-api")
const { useDb } = require("../../../../helpers/use-db")
const adminSeeder = require("../../../helpers/admin-seeder")
const {
simpleRegionFactory,
simpleShippingOptionFactory,
simpleOrderFactory
} = require("../../../factories");
jest.setTimeout(30000)
describe("[MEDUSA_FF_TAX_INCLUSIVE_PRICING] /admin/orders", () => {
let medusaProcess
let dbConnection
beforeAll(async () => {
const cwd = path.resolve(path.join(__dirname, "..", "..", ".."))
const [process, connection] = await startServerWithEnvironment({
cwd,
env: { MEDUSA_FF_TAX_INCLUSIVE_PRICING: true },
verbose: false,
})
dbConnection = connection
medusaProcess = process
})
afterAll(async () => {
const db = useDb()
await db.shutdown()
medusaProcess.kill()
})
describe("POST /admin/orders/:id/shipping-methods", () => {
let includesTaxShippingOption
let order
beforeEach(async () => {
try {
await adminSeeder(dbConnection)
const shippingAddress = {
id: "test-shipping-address",
first_name: "lebron",
country_code: "us",
}
const region = await simpleRegionFactory(dbConnection, {
id: "test-region"
})
order = await simpleOrderFactory(dbConnection, {
id: "test-order",
region: region.id,
shipping_address: shippingAddress,
currency_code: "usd",
})
includesTaxShippingOption = await simpleShippingOptionFactory(dbConnection, {
includes_tax: true,
region_id: region.id
})
} catch (err) {
console.log(err)
}
})
afterEach(async() => {
const db = useDb()
return await db.teardown()
})
it("should add a normal shipping method to the order", async () => {
const api = useApi()
const orderWithShippingMethodRes = await api.post(
`/admin/orders/${order.id}/shipping-methods`,
{
option_id: includesTaxShippingOption.id,
price: 10,
},
{
headers: {
Authorization: "Bearer test_token",
},
}
)
expect(orderWithShippingMethodRes.status).toEqual(200)
expect(orderWithShippingMethodRes.data.order.shipping_methods)
.toEqual(expect.arrayContaining([
expect.objectContaining({
shipping_option_id: includesTaxShippingOption.id,
includes_tax: true,
})
]))
})
})
})
@@ -7,21 +7,21 @@ const {
ShippingMethod, ShippingMethod,
} = require("@medusajs/medusa") } = require("@medusajs/medusa")
const setupServer = require("../../../helpers/setup-server") const setupServer = require("../../../../helpers/setup-server")
const { useApi } = require("../../../helpers/use-api") const { useApi } = require("../../../../helpers/use-api")
const { initDb, useDb } = require("../../../helpers/use-db") const { initDb, useDb } = require("../../../../helpers/use-db")
const orderSeeder = require("../../helpers/order-seeder") const orderSeeder = require("../../../helpers/order-seeder")
const swapSeeder = require("../../helpers/swap-seeder") const swapSeeder = require("../../../helpers/swap-seeder")
const adminSeeder = require("../../helpers/admin-seeder") const adminSeeder = require("../../../helpers/admin-seeder")
const claimSeeder = require("../../helpers/claim-seeder") const claimSeeder = require("../../../helpers/claim-seeder")
const { const {
expectPostCallToReturn, expectPostCallToReturn,
expectAllPostCallsToReturn, expectAllPostCallsToReturn,
callGet, callGet,
partial, partial,
} = require("../../helpers/call-helpers") } = require("../../../helpers/call-helpers")
jest.setTimeout(30000) jest.setTimeout(30000)
@@ -30,7 +30,7 @@ describe("/admin/orders", () => {
let dbConnection let dbConnection
beforeAll(async () => { beforeAll(async () => {
const cwd = path.resolve(path.join(__dirname, "..", "..")) const cwd = path.resolve(path.join(__dirname, "..", "..", ".."))
dbConnection = await initDb({ cwd }) dbConnection = await initDb({ cwd })
medusaProcess = await setupServer({ cwd }) medusaProcess = await setupServer({ cwd })
}) })
@@ -1,7 +1,7 @@
const { PriceList, CustomerGroup } = require("@medusajs/medusa")
const path = require("path") const path = require("path")
const setupServer = require("../../../helpers/setup-server") const setupServer = require("../../../helpers/setup-server")
const startServerWithEnvironment = require("../../../helpers/start-server-with-environment").default
const { useApi } = require("../../../helpers/use-api") const { useApi } = require("../../../helpers/use-api")
const { useDb, initDb } = require("../../../helpers/use-db") const { useDb, initDb } = require("../../../helpers/use-db")
@@ -9,14 +9,17 @@ const {
simpleProductFactory, simpleProductFactory,
simplePriceListFactory, simplePriceListFactory,
} = require("../../factories") } = require("../../factories")
const {
simpleCustomerGroupFactory,
} = require("../../factories/simple-customer-group-factory")
const adminSeeder = require("../../helpers/admin-seeder") const adminSeeder = require("../../helpers/admin-seeder")
const customerSeeder = require("../../helpers/customer-seeder") const customerSeeder = require("../../helpers/customer-seeder")
const priceListSeeder = require("../../helpers/price-list-seeder") const priceListSeeder = require("../../helpers/price-list-seeder")
const productSeeder = require("../../helpers/product-seeder") const productSeeder = require("../../helpers/product-seeder")
const adminReqConfig = {
headers: {
Authorization: "Bearer test_token",
},
}
jest.setTimeout(30000) jest.setTimeout(30000)
describe("/admin/price-lists", () => { describe("/admin/price-lists", () => {
@@ -1141,54 +1144,52 @@ describe("/admin/price-lists", () => {
expect(response.status).toEqual(200) expect(response.status).toEqual(200)
expect(response.data.count).toEqual(2) expect(response.data.count).toEqual(2)
expect(response.data.products).toHaveLength(2) expect(response.data.products).toHaveLength(2)
expect(response.data.products).toEqual( expect(response.data.products).toEqual([
expect.arrayContaining([
expect.objectContaining({ expect.objectContaining({
id: "test-prod-1", id: "test-prod-1",
variants: [ variants: expect.arrayContaining([
expect.objectContaining({ expect.objectContaining({
id: "test-variant-1", id: "test-variant-1",
prices: [ prices: expect.arrayContaining([
expect.objectContaining({ currency_code: "usd", amount: 100 }), expect.objectContaining({ currency_code: "usd", amount: 100 }),
expect.objectContaining({ expect.objectContaining({
currency_code: "usd", currency_code: "usd",
amount: 150, amount: 150,
price_list_id: "test-list", price_list_id: "test-list",
}), }),
], ],)
}), }),
expect.objectContaining({ expect.objectContaining({
id: "test-variant-2", id: "test-variant-2",
prices: [ prices: expect.arrayContaining([
expect.objectContaining({ currency_code: "usd", amount: 100 }), expect.objectContaining({ currency_code: "usd", amount: 100 }),
], ]),
}), }),
], ]),
}), }),
expect.objectContaining({ expect.objectContaining({
id: "test-prod-2", id: "test-prod-2",
variants: [ variants: expect.arrayContaining([
expect.objectContaining({ expect.objectContaining({
id: "test-variant-3", id: "test-variant-3",
prices: [ prices: expect.arrayContaining([
expect.objectContaining({ currency_code: "usd", amount: 100 }), expect.objectContaining({ currency_code: "usd", amount: 100 }),
], ]),
}), }),
expect.objectContaining({ expect.objectContaining({
id: "test-variant-4", id: "test-variant-4",
prices: [ prices: expect.arrayContaining([
expect.objectContaining({ currency_code: "usd", amount: 100 }), expect.objectContaining({ currency_code: "usd", amount: 100 }),
expect.objectContaining({ expect.objectContaining({
currency_code: "usd", currency_code: "usd",
amount: 150, amount: 150,
price_list_id: "test-list", price_list_id: "test-list",
}), }),
], ]),
}), }),
], ]),
}), }),
]) ])
)
}) })
it("lists only product 2", async () => { it("lists only product 2", async () => {
@@ -1387,3 +1388,112 @@ describe("/admin/price-lists", () => {
}) })
}) })
}) })
describe("[MEDUSA_FF_TAX_INCLUSIVE_PRICING] /admin/price-lists", () => {
let medusaProcess
let dbConnection
beforeAll(async () => {
const cwd = path.resolve(path.join(__dirname, "..", ".."))
const [process, connection] = await startServerWithEnvironment({
cwd,
env: { MEDUSA_FF_TAX_INCLUSIVE_PRICING: true },
verbose: false,
})
dbConnection = connection
medusaProcess = process
})
afterAll(async () => {
const db = useDb()
await db.shutdown()
medusaProcess.kill()
})
describe("POST /admin/price-list", () => {
const priceListIncludesTaxId = "price-list-1-includes-tax"
beforeEach(async () => {
try {
await adminSeeder(dbConnection)
await customerSeeder(dbConnection)
await productSeeder(dbConnection)
await simplePriceListFactory(dbConnection, {
id: priceListIncludesTaxId,
})
} catch (err) {
console.log(err)
throw err
}
})
afterEach(async () => {
const db = useDb()
await db.teardown()
})
it("should creates a price list that includes tax", async () => {
const api = useApi()
const payload = {
name: "VIP Summer sale",
description: "Summer sale for VIP customers. 25% off selected items.",
type: "sale",
status: "active",
starts_at: "2022-07-01T00:00:00.000Z",
ends_at: "2022-07-31T00:00:00.000Z",
customer_groups: [
{
id: "customer-group-1",
},
],
prices: [
{
amount: 85,
currency_code: "usd",
variant_id: "test-variant",
},
],
includes_tax: true,
}
const response = await api
.post("/admin/price-lists", payload, adminReqConfig)
.catch((err) => {
console.warn(err.response.data)
})
expect(response.status).toEqual(200)
expect(response.data.price_list).toEqual(
expect.objectContaining({
id: expect.any(String),
includes_tax: true,
})
)
})
it("should update a price list that include_tax", async () => {
const api = useApi()
let response = await api
.get(`/admin/price-lists/${priceListIncludesTaxId}`, adminReqConfig)
.catch((err) => {
console.log(err)
})
expect(response.data.price_list.includes_tax).toBe(false)
response = await api
.post(
`/admin/price-lists/${priceListIncludesTaxId}`,
{ includes_tax: true, },
adminReqConfig
).catch((err) => {
console.log(err)
})
expect(response.data.price_list.includes_tax).toBe(true)
})
})
})
@@ -2,9 +2,17 @@ const path = require("path")
const { Region } = require("@medusajs/medusa") const { Region } = require("@medusajs/medusa")
const setupServer = require("../../../helpers/setup-server") const setupServer = require("../../../helpers/setup-server")
const startServerWithEnvironment = require("../../../helpers/start-server-with-environment").default
const { useApi } = require("../../../helpers/use-api") const { useApi } = require("../../../helpers/use-api")
const { initDb, useDb } = require("../../../helpers/use-db") const { initDb, useDb } = require("../../../helpers/use-db")
const adminSeeder = require("../../helpers/admin-seeder") const adminSeeder = require("../../helpers/admin-seeder")
const { simpleRegionFactory } = require("../../factories");
const adminReqConfig = {
headers: {
Authorization: "Bearer test_token",
},
}
jest.setTimeout(30000) jest.setTimeout(30000)
@@ -286,3 +294,99 @@ describe("/admin/regions", () => {
}) })
}) })
}) })
describe("[MEDUSA_FF_TAX_INCLUSIVE_PRICING] /admin/regions", () => {
let medusaProcess
let dbConnection
beforeAll(async () => {
const cwd = path.resolve(path.join(__dirname, "..", ".."))
const [process, connection] = await startServerWithEnvironment({
cwd,
env: { MEDUSA_FF_TAX_INCLUSIVE_PRICING: true },
verbose: false,
})
dbConnection = connection
medusaProcess = process
})
afterAll(async () => {
const db = useDb()
await db.shutdown()
medusaProcess.kill()
})
describe("POST /admin/regions/:id", () => {
const region1TaxInclusiveId = "region-1-tax-inclusive"
beforeEach(async () => {
try {
await adminSeeder(dbConnection)
await simpleRegionFactory(dbConnection, {
id: region1TaxInclusiveId,
countries: ["fr"],
})
} catch (err) {
console.log(err)
throw err
}
})
afterEach(async () => {
const db = useDb()
await db.teardown()
})
it("should allow to create a region that includes tax", async function () {
const api = useApi()
const payload = {
name: "region-including-taxes",
currency_code: "usd",
tax_rate: 0,
payment_providers: ["test-pay"],
fulfillment_providers: ["test-ful"],
countries: ["us"],
includes_tax: true,
}
let response = await api
.post(`/admin/regions`, payload, adminReqConfig)
.catch((err) => {
console.log(err)
})
expect(response.data.region).toEqual(
expect.objectContaining({
id: expect.any(String),
includes_tax: true,
name: "region-including-taxes",
})
)
});
it("should allow to update a region that includes tax", async function () {
const api = useApi()
let response = await api
.get(`/admin/regions/${region1TaxInclusiveId}`, adminReqConfig)
.catch((err) => {
console.log(err)
})
expect(response.data.region.includes_tax).toBe(false)
response = await api.post(
`/admin/regions/${region1TaxInclusiveId}`,
{
includes_tax: true,
},
adminReqConfig,
).catch((err) => {
console.log(err)
})
expect(response.data.region.includes_tax).toBe(true)
});
})
})
@@ -1,16 +1,21 @@
const path = require("path") const path = require("path")
const { const {
Region,
ShippingProfile, ShippingProfile,
ShippingOption,
ShippingOptionRequirement,
} = require("@medusajs/medusa") } = require("@medusajs/medusa")
const setupServer = require("../../../helpers/setup-server") const setupServer = require("../../../helpers/setup-server")
const startServerWithEnvironment = require("../../../helpers/start-server-with-environment").default
const { useApi } = require("../../../helpers/use-api") const { useApi } = require("../../../helpers/use-api")
const { initDb, useDb } = require("../../../helpers/use-db") const { initDb, useDb } = require("../../../helpers/use-db")
const adminSeeder = require("../../helpers/admin-seeder") const adminSeeder = require("../../helpers/admin-seeder")
const shippingOptionSeeder = require("../../helpers/shipping-option-seeder") const shippingOptionSeeder = require("../../helpers/shipping-option-seeder")
const { simpleShippingOptionFactory, simpleRegionFactory } = require("../../factories")
const adminReqConfig = {
headers: {
Authorization: "Bearer test_token",
},
}
jest.setTimeout(30000) jest.setTimeout(30000)
@@ -460,3 +465,120 @@ describe("/admin/shipping-options", () => {
}) })
}) })
}) })
describe("[MEDUSA_FF_TAX_INCLUSIVE_PRICING] /admin/shipping-options", () => {
let medusaProcess
let dbConnection
beforeAll(async () => {
const cwd = path.resolve(path.join(__dirname, "..", ".."))
const [process, connection] = await startServerWithEnvironment({
cwd,
env: { MEDUSA_FF_TAX_INCLUSIVE_PRICING: true },
verbose: false,
})
dbConnection = connection
medusaProcess = process
})
afterAll(async () => {
const db = useDb()
await db.shutdown()
medusaProcess.kill()
})
describe("POST /admin/shipping-options", () => {
const shippingOptionIncludesTaxId = "shipping-option-1-includes-tax"
let region
beforeEach(async () => {
try {
await adminSeeder(dbConnection)
region = await simpleRegionFactory(dbConnection, {
id: "region",
countries: ["fr"],
})
await simpleShippingOptionFactory(dbConnection, {
id: shippingOptionIncludesTaxId,
region_id: region.id,
})
} catch (err) {
console.log(err)
throw err
}
})
afterEach(async () => {
const db = useDb()
await db.teardown()
})
it("should creates a shipping option that includes tax", async () => {
const api = useApi()
const defaultProfile = await dbConnection.manager.findOne(ShippingProfile, {
type: "default",
})
const payload = {
name: "Test option",
amount: 100,
price_type: "flat_rate",
region_id: region.id,
provider_id: "test-ful",
data: {},
profile_id: defaultProfile.id,
includes_tax: true,
}
const response = await api
.post("/admin/shipping-options", payload, adminReqConfig)
.catch((err) => {
console.log(err)
})
expect(response.status).toEqual(200)
expect(response.data.shipping_option).toEqual(
expect.objectContaining({
id: expect.any(String),
includes_tax: true,
})
)
})
it("should update a shipping option that include_tax", async () => {
const api = useApi()
let response = await api
.get(`/admin/shipping-options/${shippingOptionIncludesTaxId}`, adminReqConfig)
.catch((err) => {
console.log(err)
})
expect(response.data.shipping_option.includes_tax).toBe(false)
const payload = {
requirements: [
{
type: "min_subtotal",
amount: 1,
},
{
type: "max_subtotal",
amount: 2,
},
],
includes_tax: true,
}
response = await api
.post(`/admin/shipping-options/${shippingOptionIncludesTaxId}`, payload, adminReqConfig)
.catch((err) => {
console.log(err)
})
expect(response.data.shipping_option.includes_tax).toBe(true)
})
})
})
@@ -52,12 +52,7 @@ describe("/admin/store", () => {
code: "usd", code: "usd",
}, },
], ],
feature_flags: [ feature_flags: expect.any(Array),
{
key: "sales_channels",
value: false,
},
],
default_currency_code: "usd", default_currency_code: "usd",
created_at: expect.any(String), created_at: expect.any(String),
updated_at: expect.any(String), updated_at: expect.any(String),
@@ -133,7 +128,8 @@ describe("/admin/store", () => {
it("successfully updates default currency code", async () => { it("successfully updates default currency code", async () => {
const api = useApi() const api = useApi()
const response = await api.post( const response = await api
.post(
"/admin/store", "/admin/store",
{ {
default_currency_code: "dkk", default_currency_code: "dkk",
@@ -142,6 +138,7 @@ describe("/admin/store", () => {
headers: { Authorization: "Bearer test_token " }, headers: { Authorization: "Bearer test_token " },
} }
) )
.catch((err) => console.log(err))
expect(response.status).toEqual(200) expect(response.status).toEqual(200)
expect(response.data.store).toMatchSnapshot({ expect(response.data.store).toMatchSnapshot({
@@ -250,12 +250,10 @@ describe("/admin/swaps", () => {
data: {}, data: {},
}) })
await api.post("/store/carts/cart-test/payment-sessions") await api.post("/store/carts/cart-test/payment-sessions")
const TEST = await api.post("/store/carts/cart-test/payment-session", { await api.post("/store/carts/cart-test/payment-session", {
provider_id: "test-pay", provider_id: "test-pay",
}) })
console.log("Testing, ", TEST.data.cart.items[0])
// ********* COMPLETE CART ********* // ********* COMPLETE CART *********
const completedOrder = await api.post("/store/carts/cart-test/complete") const completedOrder = await api.post("/store/carts/cart-test/complete")
@@ -33,9 +33,9 @@ describe("Line Item Adjustments", () => {
}) })
describe("Tests database constraints", () => { describe("Tests database constraints", () => {
let cart, let cart
discount, let discount
lineItemId = "line-test" const lineItemId = "line-test"
beforeEach(async () => { beforeEach(async () => {
await cartSeeder(dbConnection) await cartSeeder(dbConnection)
discount = await simpleDiscountFactory(dbConnection, { discount = await simpleDiscountFactory(dbConnection, {
@@ -113,7 +113,7 @@ describe("Line Item Adjustments", () => {
}) })
} }
expect(createLineItemWithAdjustment()).resolves.toEqual( await expect(createLineItemWithAdjustment()).resolves.toEqual(
expect.anything() expect.anything()
) )
}) })
@@ -131,7 +131,7 @@ describe("Line Item Adjustments", () => {
}) })
} }
expect(createAdjustmentNullDiscount()).resolves.toEqual( await expect(createAdjustmentNullDiscount()).resolves.toEqual(
expect.anything() expect.anything()
) )
}) })
@@ -155,7 +155,8 @@ describe("Line Item Adjustments", () => {
discount_id: null, discount_id: null,
}) })
} }
expect(createAdjustmentsNullDiscount()).resolves.toEqual(
await expect(createAdjustmentsNullDiscount()).resolves.toEqual(
expect.anything() expect.anything()
) )
}) })
@@ -184,7 +185,7 @@ describe("Line Item Adjustments", () => {
}) })
} }
expect(createAdjustment()).resolves.toEqual(expect.anything()) await expect(createAdjustment()).resolves.toEqual(expect.anything())
}) })
}) })
@@ -199,7 +200,7 @@ describe("Line Item Adjustments", () => {
discount_id: discount.id, discount_id: discount.id,
}) })
expect(createDuplicateAdjustment()).rejects.toEqual( await expect(createDuplicateAdjustment()).rejects.toEqual(
expect.objectContaining({ code: "23505" }) expect.objectContaining({ code: "23505" })
) )
}) })
File diff suppressed because it is too large. Load diff
@@ -0,0 +1,276 @@
const path = require("path")
const startServerWithEnvironment =
require("../../../helpers/start-server-with-environment").default
const { useApi } = require("../../../helpers/use-api")
const { useDb } = require("../../../helpers/use-db")
const { simpleProductFactory, simpleOrderFactory } = require("../../factories")
const adminSeeder = require("../../helpers/admin-seeder")
const createReturnableOrder = async (dbConnection, options) => {
await simpleProductFactory(
dbConnection,
{
id: "test-product",
variants: [{ id: "test-variant" }],
},
100
)
let discounts = []
if (options?.discount) {
discounts = [
{
code: "TESTCODE",
},
]
}
let unitPrice = options.includes_tax ? 1200 : 1000
if (options.oldTaxes) {
unitPrice = options.includes_tax ? 1125 : 1000
}
return await simpleOrderFactory(dbConnection, {
email: "test@testson.com",
tax_rate: options?.oldTaxes ? undefined : null,
region: {
id: "test-region",
name: "Test region",
tax_rate: 12.5,
},
discounts,
line_items: [
{
id: "test-item",
variant_id: "test-variant",
quantity: 2,
fulfilled_quantity: options?.shipped ? 2 : undefined,
shipped_quantity: options?.shipped ? 2 : undefined,
unit_price: unitPrice,
includes_tax: options?.includes_tax,
tax_lines: [
{
name: "default",
code: "default",
rate: 20,
},
],
},
],
})
}
jest.setTimeout(30000)
describe("[MEDUSA_FF_TAX_INCLUSIVE_PRICING] /store/carts", () => {
let medusaProcess
let dbConnection
beforeAll(async () => {
const cwd = path.resolve(path.join(__dirname, "..", ".."))
const [process, connection] = await startServerWithEnvironment({
cwd,
env: { MEDUSA_FF_TAX_INCLUSIVE_PRICING: true },
verbose: false,
})
dbConnection = connection
medusaProcess = process
})
afterAll(async () => {
const db = useDb()
await db.shutdown()
medusaProcess.kill()
})
afterEach(async () => {
const db = useDb()
return await db.teardown()
})
it("creates a return with the old tax system and tax inclusive line", async () => {
await adminSeeder(dbConnection)
const order = await createReturnableOrder(dbConnection, {
oldTaxes: true,
includes_tax: true,
})
const api = useApi()
const response = await api.post(
`/admin/orders/${order.id}/return`,
{
items: [
{
item_id: "test-item",
quantity: 1,
note: "TOO SMALL",
},
],
},
{
headers: {
authorization: "Bearer test_token",
},
}
)
expect(response.status).toEqual(200)
/*
* Region has default tax rate 12.5 therefore refund amount should be
* 1000 * 1.125 = 1125
*/
expect(response.data.order.returns[0].refund_amount).toEqual(1125)
expect(response.data.order.returns[0].items).toHaveLength(1)
expect(response.data.order.returns[0].items).toEqual(
expect.arrayContaining([
expect.objectContaining({
item_id: "test-item",
quantity: 1,
note: "TOO SMALL",
}),
])
)
})
it("creates a return with the old tax system and tax exclusive line", async () => {
await adminSeeder(dbConnection)
const order = await createReturnableOrder(dbConnection, {
oldTaxes: true,
includes_tax: false,
})
const api = useApi()
const response = await api.post(
`/admin/orders/${order.id}/return`,
{
items: [
{
item_id: "test-item",
quantity: 1,
note: "TOO SMALL",
},
],
},
{
headers: {
authorization: "Bearer test_token",
},
}
)
expect(response.status).toEqual(200)
/*
* Region has default tax rate 12.5 therefore refund amount should be
* 1000 * 1.125 = 1125
*/
expect(response.data.order.returns[0].refund_amount).toEqual(1125)
expect(response.data.order.returns[0].items).toHaveLength(1)
expect(response.data.order.returns[0].items).toEqual(
expect.arrayContaining([
expect.objectContaining({
item_id: "test-item",
quantity: 1,
note: "TOO SMALL",
}),
])
)
})
it("creates a return with tax inclusive line", async () => {
await adminSeeder(dbConnection)
const order = await createReturnableOrder(dbConnection, {
includes_tax: true,
})
const api = useApi()
const response = await api.post(
`/admin/orders/${order.id}/return`,
{
items: [
{
item_id: "test-item",
quantity: 1,
note: "TOO SMALL",
},
],
},
{
headers: {
authorization: "Bearer test_token",
},
}
)
expect(response.status).toEqual(200)
/*
* Region has a tax rate of 20% therefore refund amount should be
* 1000 * 1.2 = 1200
*/
expect(response.data.order.returns[0].refund_amount).toEqual(1200)
expect(response.data.order.returns[0].items).toHaveLength(1)
expect(response.data.order.returns[0].items).toEqual(
expect.arrayContaining([
expect.objectContaining({
item_id: "test-item",
quantity: 1,
note: "TOO SMALL",
}),
])
)
})
it("creates a return with tax exclusive line", async () => {
await adminSeeder(dbConnection)
const order = await createReturnableOrder(dbConnection, {
includes_tax: false,
})
const api = useApi()
const response = await api.post(
`/admin/orders/${order.id}/return`,
{
items: [
{
item_id: "test-item",
quantity: 1,
note: "TOO SMALL",
},
],
},
{
headers: {
authorization: "Bearer test_token",
},
}
)
expect(response.status).toEqual(200)
/*
* Region has a tax rate of 20% therefore refund amount should be
* 1000 * 1.2 = 1200
*/
expect(response.data.order.returns[0].refund_amount).toEqual(1200)
expect(response.data.order.returns[0].items).toHaveLength(1)
expect(response.data.order.returns[0].items).toEqual(
expect.arrayContaining([
expect.objectContaining({
item_id: "test-item",
quantity: 1,
note: "TOO SMALL",
}),
])
)
})
})
@@ -9,29 +9,29 @@ const {
MoneyAmount, MoneyAmount,
} = require("@medusajs/medusa") } = require("@medusajs/medusa")
const setupServer = require("../../../helpers/setup-server") const setupServer = require("../../../../helpers/setup-server")
const { useApi } = require("../../../helpers/use-api") const { useApi } = require("../../../../helpers/use-api")
const { initDb, useDb } = require("../../../helpers/use-db") const { initDb, useDb } = require("../../../../helpers/use-db")
const cartSeeder = require("../../helpers/cart-seeder") const cartSeeder = require("../../../helpers/cart-seeder")
const productSeeder = require("../../helpers/product-seeder") const productSeeder = require("../../../helpers/product-seeder")
const swapSeeder = require("../../helpers/swap-seeder") const swapSeeder = require("../../../helpers/swap-seeder")
const { const {
simpleCartFactory, simpleCartFactory,
simpleRegionFactory, simpleRegionFactory,
simpleProductFactory, simpleProductFactory,
simpleShippingOptionFactory, simpleShippingOptionFactory,
simpleLineItemFactory, simpleLineItemFactory,
} = require("../../factories") } = require("../../../factories")
const { const {
simpleDiscountFactory, simpleDiscountFactory,
} = require("../../factories/simple-discount-factory") } = require("../../../factories/simple-discount-factory")
const { const {
simpleCustomerFactory, simpleCustomerFactory,
} = require("../../factories/simple-customer-factory") } = require("../../../factories/simple-customer-factory")
const { const {
simpleCustomerGroupFactory, simpleCustomerGroupFactory,
} = require("../../factories/simple-customer-group-factory") } = require("../../../factories/simple-customer-group-factory")
jest.setTimeout(30000) jest.setTimeout(30000)
@@ -45,7 +45,7 @@ describe("/store/carts", () => {
} }
beforeAll(async () => { beforeAll(async () => {
const cwd = path.resolve(path.join(__dirname, "..", "..")) const cwd = path.resolve(path.join(__dirname, "..", "..", ".."))
dbConnection = await initDb({ cwd }) dbConnection = await initDb({ cwd })
medusaProcess = await setupServer({ cwd, verbose: false }) medusaProcess = await setupServer({ cwd, verbose: false })
}) })
@@ -1813,7 +1813,7 @@ describe("/store/carts", () => {
type: "swap", type: "swap",
}) })
const cartWithCustomSo = await manager.save(_cart) await manager.save(_cart)
await manager.insert(CustomShippingOption, { await manager.insert(CustomShippingOption, {
id: "another-cso-test", id: "another-cso-test",
@@ -0,0 +1,538 @@
const path = require("path")
const startServerWithEnvironment =
require("../../../../helpers/start-server-with-environment").default
const { useApi } = require("../../../../helpers/use-api")
const { useDb } = require("../../../../helpers/use-db")
const {
simpleCartFactory,
simpleRegionFactory,
simpleShippingOptionFactory,
simpleCustomShippingOptionFactory,
simpleProductFactory,
simplePriceListFactory,
simpleDiscountFactory,
} = require("../../../factories")
const { IdMap } = require("medusa-test-utils")
jest.setTimeout(30000)
describe("[MEDUSA_FF_TAX_INCLUSIVE_PRICING] /store/carts", () => {
let medusaProcess
let dbConnection
beforeAll(async () => {
const cwd = path.resolve(path.join(__dirname, "..", "..", ".."))
const [process, connection] = await startServerWithEnvironment({
cwd,
env: { MEDUSA_FF_TAX_INCLUSIVE_PRICING: true },
verbose: false,
})
dbConnection = connection
medusaProcess = process
})
afterAll(async () => {
const db = useDb()
await db.shutdown()
medusaProcess.kill()
})
describe("POST /store/carts/:id/shipping-methods", () => {
let includesTaxShippingOption
let cart
let customSoCart
beforeEach(async () => {
try {
const shippingAddress = {
id: "test-shipping-address",
first_name: "lebron",
country_code: "us",
}
const region = await simpleRegionFactory(dbConnection, {
id: "test-region",
})
cart = await simpleCartFactory(dbConnection, {
id: "test-cart",
email: "some-customer1@email.com",
region: region.id,
shipping_address: shippingAddress,
currency_code: "usd",
})
customSoCart = await simpleCartFactory(dbConnection, {
id: "test-cart-with-cso",
email: "some-customer2@email.com",
region: region.id,
shipping_address: shippingAddress,
currency_code: "usd",
})
includesTaxShippingOption = await simpleShippingOptionFactory(
dbConnection,
{
includes_tax: true,
region_id: region.id,
}
)
await simpleCustomShippingOptionFactory(dbConnection, {
id: "another-cso-test",
cart_id: customSoCart.id,
shipping_option_id: includesTaxShippingOption.id,
price: 5,
})
} catch (err) {
console.log(err)
}
})
afterEach(async () => {
const db = useDb()
return await db.teardown()
})
it("should add a normal shipping method to the cart", async () => {
const api = useApi()
const cartWithShippingMethodRes = await api.post(
`/store/carts/${cart.id}/shipping-methods`,
{
option_id: includesTaxShippingOption.id,
},
{ withCredentials: true }
)
expect(cartWithShippingMethodRes.status).toEqual(200)
expect(cartWithShippingMethodRes.data.cart.shipping_methods).toEqual(
expect.arrayContaining([
expect.objectContaining({
shipping_option_id: includesTaxShippingOption.id,
includes_tax: true,
}),
])
)
})
it("should add a custom shipping method to the cart", async () => {
const api = useApi()
const cartWithCustomShippingMethodRes = await api
.post(
`/store/carts/${customSoCart.id}/shipping-methods`,
{
option_id: includesTaxShippingOption.id,
},
{ withCredentials: true }
)
.catch((err) => err.response)
expect(cartWithCustomShippingMethodRes.status).toEqual(200)
expect(
cartWithCustomShippingMethodRes.data.cart.shipping_methods
).toEqual(
expect.arrayContaining([
expect.objectContaining({
shipping_option_id: includesTaxShippingOption.id,
includes_tax: true,
price: 5,
}),
])
)
})
})
describe("POST /store/carts/:id", () => {
const variantId1 = IdMap.getId("test-variant-1")
const variantId2 = IdMap.getId("test-variant-2")
const productId1 = IdMap.getId("test-product-1")
const productId2 = IdMap.getId("test-product-2")
const regionId = IdMap.getId("test-region")
const regionData = {
id: regionId,
includes_tax: false,
currency_code: "usd",
countries: ["us"],
tax_rate: 20,
name: "region test",
}
const buildProductData = (productId, variantId) => {
return {
id: productId,
variants: [
{
id: variantId,
prices: [],
},
],
}
}
const buildPriceListData = (variantId, price, includesTax) => {
return {
status: "active",
type: "sale",
prices: [
{
variant_id: variantId,
amount: price,
currency_code: "usd",
region_id: regionId,
},
],
includes_tax: includesTax,
}
}
const customnerPayload = {
email: "adrien@test.dk",
password: "adrientest",
first_name: "adrien",
last_name: "adrien",
}
const createCartPayload = {
region_id: regionId,
items: [
{
variant_id: variantId1,
quantity: 1,
},
{
variant_id: variantId2,
quantity: 1,
},
],
}
describe("with a cart with full tax exclusive variant pricing", () => {
beforeEach(async () => {
await simpleRegionFactory(dbConnection, regionData)
await simpleProductFactory(
dbConnection,
buildProductData(productId1, variantId1)
)
await simplePriceListFactory(
dbConnection,
buildPriceListData(variantId1, 100, false)
)
await simpleProductFactory(
dbConnection,
buildProductData(productId2, variantId2)
)
await simplePriceListFactory(
dbConnection,
buildPriceListData(variantId2, 100, false)
)
})
afterEach(async () => {
const db = useDb()
return await db.teardown()
})
it("should calculates correct payment totals on cart completion", async () => {
const api = useApi()
const customerRes = await api.post(
"/store/customers",
customnerPayload,
{ withCredentials: true }
)
const createCartRes = await api.post("/store/carts", createCartPayload)
const cart = createCartRes.data.cart
await api.post(`/store/carts/${cart.id}`, {
customer_id: customerRes.data.customer.id,
})
await api.post(`/store/carts/${cart.id}/payment-sessions`)
const createdOrder = await api.post(
`/store/carts/${cart.id}/complete-cart`
)
expect(createdOrder.data.type).toEqual("order")
expect(createdOrder.data.data.discount_total).toEqual(0)
expect(createdOrder.data.data.subtotal).toEqual(200)
expect(createdOrder.data.data.total).toEqual(240)
expect(createdOrder.status).toEqual(200)
})
})
describe("with a cart with full tax inclusive variant pricing", () => {
beforeEach(async () => {
await simpleRegionFactory(dbConnection, regionData)
await simpleProductFactory(
dbConnection,
buildProductData(productId1, variantId1)
)
await simplePriceListFactory(
dbConnection,
buildPriceListData(variantId1, 120, true)
)
await simpleProductFactory(
dbConnection,
buildProductData(productId2, variantId2)
)
await simplePriceListFactory(
dbConnection,
buildPriceListData(variantId2, 120, true)
)
})
afterEach(async () => {
const db = useDb()
return await db.teardown()
})
it("should calculates correct payment totals on cart completion", async () => {
const api = useApi()
const customerRes = await api.post(
"/store/customers",
customnerPayload,
{ withCredentials: true }
)
const createCartRes = await api.post("/store/carts", createCartPayload)
const cart = createCartRes.data.cart
await api.post(`/store/carts/${cart.id}`, {
customer_id: customerRes.data.customer.id,
})
await api.post(`/store/carts/${cart.id}/payment-sessions`)
const createdOrder = await api.post(
`/store/carts/${cart.id}/complete-cart`
)
expect(createdOrder.data.type).toEqual("order")
expect(createdOrder.data.data.discount_total).toEqual(0)
expect(createdOrder.data.data.subtotal).toEqual(200)
expect(createdOrder.data.data.total).toEqual(240)
expect(createdOrder.status).toEqual(200)
})
})
describe("with a cart mixing tax inclusive and exclusive variant pricing", () => {
beforeEach(async () => {
await simpleRegionFactory(dbConnection, regionData)
await simpleProductFactory(
dbConnection,
buildProductData(productId1, variantId1)
)
await simplePriceListFactory(
dbConnection,
buildPriceListData(variantId1, 120, true)
)
await simpleProductFactory(
dbConnection,
buildProductData(productId2, variantId2)
)
await simplePriceListFactory(
dbConnection,
buildPriceListData(variantId2, 100, false)
)
})
afterEach(async () => {
const db = useDb()
return await db.teardown()
})
it("should calculates correct payment totals on cart completion", async () => {
const api = useApi()
const customerRes = await api.post(
"/store/customers",
customnerPayload,
{ withCredentials: true }
)
const createCartRes = await api.post("/store/carts", createCartPayload)
const cart = createCartRes.data.cart
await api.post(`/store/carts/${cart.id}`, {
customer_id: customerRes.data.customer.id,
})
await api.post(`/store/carts/${cart.id}/payment-sessions`)
const createdOrder = await api.post(
`/store/carts/${cart.id}/complete-cart`
)
expect(createdOrder.data.type).toEqual("order")
expect(createdOrder.data.data.discount_total).toEqual(0)
expect(createdOrder.data.data.subtotal).toEqual(200)
expect(createdOrder.data.data.total).toEqual(240)
expect(createdOrder.status).toEqual(200)
})
})
})
describe("POST /store/carts/:id/line-items", () => {
const cartIdWithItemPercentageDiscount =
"test-cart-w-item-percentage-discount"
const percentage15discountId = IdMap.getId("percentage15discountId")
const variantId1 = IdMap.getId("test-variant-1")
const variantId2 = IdMap.getId("test-variant-2")
const productId1 = IdMap.getId("test-product-1")
const productId2 = IdMap.getId("test-product-2")
const regionId = IdMap.getId("test-region")
const regionData = {
id: regionId,
includes_tax: false,
currency_code: "usd",
countries: ["us"],
tax_rate: 20,
name: "region test",
}
const buildProductData = (productId, variantId) => {
return {
id: productId,
variants: [
{
id: variantId,
prices: [],
},
],
}
}
const buildPriceListData = (variantId, price, includesTax) => {
return {
status: "active",
type: "sale",
prices: [
{
variant_id: variantId,
amount: price,
currency_code: "usd",
region_id: regionId,
},
],
includes_tax: includesTax,
}
}
describe("with a cart mixing tax inclusive and exclusive variant pricing", () => {
beforeEach(async () => {
const region = await simpleRegionFactory(dbConnection, regionData)
await simpleCartFactory(dbConnection, {
id: cartIdWithItemPercentageDiscount,
region,
})
await simpleProductFactory(
dbConnection,
buildProductData(productId1, variantId1)
)
await simplePriceListFactory(
dbConnection,
buildPriceListData(variantId1, 120, true)
)
await simpleProductFactory(
dbConnection,
buildProductData(productId2, variantId2)
)
await simplePriceListFactory(
dbConnection,
buildPriceListData(variantId2, 100, false)
)
const tenDaysAgo = ((today) =>
new Date(today.setDate(today.getDate() - 10)))(new Date())
const tenDaysFromToday = ((today) =>
new Date(today.setDate(today.getDate() + 10)))(new Date())
await simpleDiscountFactory(dbConnection, {
id: percentage15discountId,
code: percentage15discountId,
regions: [regionId],
rule: {
type: "percentage",
value: "15",
allocation: "item",
},
starts_at: tenDaysAgo,
ends_at: tenDaysFromToday,
})
})
afterEach(async () => {
const db = useDb()
return await db.teardown()
})
it("calculates correct item totals for percentage discount with mix of tax inclusive/exclusive items", async () => {
const api = useApi()
await api.post(`/store/carts/${cartIdWithItemPercentageDiscount}`, {
region_id: regionId,
discounts: [{ code: percentage15discountId }],
})
await api.post(
`/store/carts/${cartIdWithItemPercentageDiscount}/line-items`,
{
variant_id: variantId1,
quantity: 2,
},
{ withCredentials: true }
)
const response = await api.post(
`/store/carts/${cartIdWithItemPercentageDiscount}/line-items`,
{
variant_id: variantId2,
quantity: 2,
},
{ withCredentials: true }
)
const expectedItemTotals = {
subtotal: 200,
gift_card_total: 0,
discount_total: 30,
total: 204,
original_total: 240,
original_tax_total: 40,
tax_total: 34,
}
const expectedAdjustment = {
amount: 30,
discount_id: percentage15discountId,
description: "discount",
}
expect(response.data.cart.items).toHaveLength(2)
expect(response.data.cart.items).toEqual(
expect.arrayContaining([
expect.objectContaining({
includes_tax: true,
cart_id: cartIdWithItemPercentageDiscount,
unit_price: 120,
variant_id: variantId1,
quantity: 2,
adjustments: [expect.objectContaining(expectedAdjustment)],
...expectedItemTotals,
}),
expect.objectContaining({
includes_tax: false,
cart_id: cartIdWithItemPercentageDiscount,
unit_price: 100,
variant_id: variantId2,
quantity: 2,
adjustments: [expect.objectContaining(expectedAdjustment)],
...expectedItemTotals,
}),
])
)
})
})
})
})
@@ -0,0 +1,106 @@
const path = require("path")
const { useApi } = require("../../../../helpers/use-api")
const { useDb } = require("../../../../helpers/use-db")
const startServerWithEnvironment =
require("../../../../helpers/start-server-with-environment").default
const {
simpleOrderFactory,
simpleProductFactory,
} = require("../../../factories")
jest.setTimeout(30000)
describe("[MEDUSA_FF_TAX_INCLUSIVE_PRICING]: Order Taxes", () => {
let medusaProcess
let dbConnection
beforeAll(async () => {
const cwd = path.resolve(path.join(__dirname, "..", "..", ".."))
const [process, connection] = await startServerWithEnvironment({
cwd,
env: { MEDUSA_FF_TAX_INCLUSIVE_PRICING: true },
verbose: false,
})
dbConnection = connection
medusaProcess = process
})
afterAll(async () => {
const db = useDb()
await db.shutdown()
medusaProcess.kill()
})
afterEach(async () => {
const db = useDb()
return await db.teardown()
})
test("calculates taxes w. tax inclusive shipping method price", async () => {
await simpleProductFactory(
dbConnection,
{
id: "test-product",
variants: [
{
id: "test-variant",
},
],
},
100
)
const order = await simpleOrderFactory(
dbConnection,
{
email: "test@testson.com",
tax_rate: null,
region: {
id: "test-region",
name: "Test region",
tax_rate: null,
},
shipping_methods: [
{
price: 110,
includes_tax: true,
shipping_option: {
region_id: "test-region",
},
tax_lines: [
{
rate: 10,
name: "default",
code: "default",
},
],
},
],
line_items: [
{
variant_id: "test-variant",
unit_price: 1000,
tax_lines: [
{
rate: 20,
name: "default",
code: "default",
},
],
},
],
},
100
)
const api = useApi()
const response = await api.get(`/store/orders/${order.id}`)
expect(response.status).toEqual(200)
expect(response.data.order.tax_total).toEqual(210)
expect(response.data.order.total).toEqual(1310)
})
})
@@ -1,8 +1,8 @@
const path = require("path") const path = require("path")
const setupServer = require("../../../helpers/setup-server") const setupServer = require("../../../../helpers/setup-server")
const { useApi } = require("../../../helpers/use-api") const { useApi } = require("../../../../helpers/use-api")
const { initDb, useDb } = require("../../../helpers/use-db") const { initDb, useDb } = require("../../../../helpers/use-db")
const { const {
simpleOrderFactory, simpleOrderFactory,
@@ -10,7 +10,7 @@ const {
simpleCartFactory, simpleCartFactory,
simpleProductFactory, simpleProductFactory,
simpleProductTaxRateFactory, simpleProductTaxRateFactory,
} = require("../../factories") } = require("../../../factories")
jest.setTimeout(30000) jest.setTimeout(30000)
@@ -24,7 +24,7 @@ describe("Order Taxes", () => {
} }
beforeAll(async () => { beforeAll(async () => {
const cwd = path.resolve(path.join(__dirname, "..", "..")) const cwd = path.resolve(path.join(__dirname, "..", "..", ".."))
dbConnection = await initDb({ cwd }) dbConnection = await initDb({ cwd })
medusaProcess = await setupServer({ cwd }) medusaProcess = await setupServer({ cwd })
}) })
+1
View File
@@ -17,3 +17,4 @@ export * from "./simple-product-type-tax-rate-factory"
export * from "./simple-price-list-factory" export * from "./simple-price-list-factory"
export * from "./simple-batch-job-factory" export * from "./simple-batch-job-factory"
export * from "./simple-sales-channel-factory" export * from "./simple-sales-channel-factory"
export * from "./simple-custom-shipping-option-factory"
@@ -0,0 +1,36 @@
import { Connection } from "typeorm"
import faker from "faker"
import {
CustomShippingOption,
} from "@medusajs/medusa"
export type CustomShippingOptionFactoryData = {
id?: string
cart_id: string
shipping_option_id: string
price?: number
metadata?: Record<string, unknown>
}
export const simpleCustomShippingOptionFactory = async (
connection: Connection,
data: CustomShippingOptionFactoryData,
seed?: number
): Promise<CustomShippingOption> => {
if (typeof seed !== "undefined") {
faker.seed(seed)
}
const manager = connection.manager
const customShippingOptionData = {
id: data.id ?? `custon-simple-so-${Math.random() * 1000}`,
price: typeof data.price !== "undefined" ? data.price : 500,
cart_id: data.cart_id,
shipping_option_id: data.shipping_option_id,
metadata: data.metadata ?? {}
}
const created = manager.create(CustomShippingOption, customShippingOptionData)
return await manager.save(created)
}
@@ -12,7 +12,7 @@ import { DiscountConditionJoinTableForeignKey } from "@medusajs/medusa/dist/repo
import faker from "faker" import faker from "faker"
import { Connection } from "typeorm" import { Connection } from "typeorm"
export type DiscuntConditionFactoryData = { export type DiscountConditionFactoryData = {
id?: string id?: string
rule_id: string rule_id: string
type: DiscountConditionType type: DiscountConditionType
@@ -67,7 +67,7 @@ const getJoinTableResourceIdentifiers = (type: string) => {
export const simpleDiscountConditionFactory = async ( export const simpleDiscountConditionFactory = async (
connection: Connection, connection: Connection,
data: DiscuntConditionFactoryData, data: DiscountConditionFactoryData,
seed?: number seed?: number
): Promise<void> => { ): Promise<void> => {
if (typeof seed !== "undefined") { if (typeof seed !== "undefined") {
@@ -7,7 +7,7 @@ import {
import faker from "faker" import faker from "faker"
import { Connection } from "typeorm" import { Connection } from "typeorm"
import { import {
DiscuntConditionFactoryData, DiscountConditionFactoryData,
simpleDiscountConditionFactory, simpleDiscountConditionFactory,
} from "./simple-discount-condition-factory" } from "./simple-discount-condition-factory"
@@ -15,7 +15,7 @@ export type DiscountRuleFactoryData = {
type?: DiscountRuleType type?: DiscountRuleType
value?: number value?: number
allocation?: AllocationType allocation?: AllocationType
conditions: DiscuntConditionFactoryData[] conditions: DiscountConditionFactoryData[]
} }
export type DiscountFactoryData = { export type DiscountFactoryData = {
@@ -24,6 +24,8 @@ export type DiscountFactoryData = {
is_dynamic?: boolean is_dynamic?: boolean
rule?: DiscountRuleFactoryData rule?: DiscountRuleFactoryData
regions?: string[] regions?: string[]
starts_at?: Date
ends_at?: Date
} }
export const simpleDiscountFactory = async ( export const simpleDiscountFactory = async (
@@ -37,7 +39,7 @@ export const simpleDiscountFactory = async (
const manager = connection.manager const manager = connection.manager
const ruleData = data.rule ?? {} const ruleData = data.rule ?? ({} as DiscountRuleFactoryData)
const ruleToSave = manager.create(DiscountRule, { const ruleToSave = manager.create(DiscountRule, {
type: ruleData.type ?? DiscountRuleType.PERCENTAGE, type: ruleData.type ?? DiscountRuleType.PERCENTAGE,
value: ruleData.value ?? 10, value: ruleData.value ?? 10,
@@ -63,8 +65,9 @@ export const simpleDiscountFactory = async (
rule_id: dRule.id, rule_id: dRule.id,
code: data.code ?? "TESTCODE", code: data.code ?? "TESTCODE",
regions: data.regions?.map((r) => ({ id: r })) || [], regions: data.regions?.map((r) => ({ id: r })) || [],
starts_at: data.starts_at,
ends_at: data.ends_at,
}) })
const discount = await manager.save(toSave) return await manager.save(toSave)
return discount
} }
@@ -30,6 +30,7 @@ export type LineItemFactoryData = {
returned_quantity?: boolean returned_quantity?: boolean
tax_lines?: TaxLineFactoryData[] tax_lines?: TaxLineFactoryData[]
adjustments: LineItemAdjustmentFactoryData[] adjustments: LineItemAdjustmentFactoryData[]
includes_tax?: boolean
} }
export const simpleLineItemFactory = async ( export const simpleLineItemFactory = async (
@@ -70,6 +71,7 @@ export const simpleLineItemFactory = async (
shipped_quantity: data.shipped_quantity || null, shipped_quantity: data.shipped_quantity || null,
returned_quantity: data.returned_quantity || null, returned_quantity: data.returned_quantity || null,
adjustments: data.adjustments, adjustments: data.adjustments,
includes_tax: data.includes_tax,
}) })
const line = await manager.save(toSave) const line = await manager.save(toSave)
@@ -26,6 +26,7 @@ export type PriceListFactoryData = {
ends_at?: Date ends_at?: Date
customer_groups?: string[] customer_groups?: string[]
prices?: ProductListPrice[] prices?: ProductListPrice[]
includes_tax?: boolean
} }
export const simplePriceListFactory = async ( export const simplePriceListFactory = async (
@@ -59,6 +60,7 @@ export const simplePriceListFactory = async (
starts_at: data.starts_at || null, starts_at: data.starts_at || null,
ends_at: data.ends_at || null, ends_at: data.ends_at || null,
customer_groups: customerGroups, customer_groups: customerGroups,
includes_tax: data.includes_tax,
} }
const toSave = manager.create(PriceList, toCreate) const toSave = manager.create(PriceList, toCreate)
@@ -10,6 +10,8 @@ export type RegionFactoryData = {
countries?: string[] countries?: string[]
automatic_taxes?: boolean automatic_taxes?: boolean
gift_cards_taxable?: boolean gift_cards_taxable?: boolean
fulfillment_providers?: { id: string }[]
includes_tax?: boolean
} }
export const simpleRegionFactory = async ( export const simpleRegionFactory = async (
@@ -30,7 +32,9 @@ export const simpleRegionFactory = async (
currency_code: data.currency_code || "usd", currency_code: data.currency_code || "usd",
tax_rate: data.tax_rate || 0, tax_rate: data.tax_rate || 0,
payment_providers: [{ id: "test-pay" }], payment_providers: [{ id: "test-pay" }],
fulfillment_providers: data.fulfillment_providers ?? [{ id: "test-ful" }],
gift_cards_taxable: data.gift_cards_taxable ?? true, gift_cards_taxable: data.gift_cards_taxable ?? true,
includes_tax: data.includes_tax,
automatic_taxes: automatic_taxes:
typeof data.automatic_taxes !== "undefined" ? data.automatic_taxes : true, typeof data.automatic_taxes !== "undefined" ? data.automatic_taxes : true,
}) })
@@ -15,6 +15,7 @@ export type ShippingMethodFactoryData = {
price?: number price?: number
shipping_option: string | ShippingOptionFactoryData shipping_option: string | ShippingOptionFactoryData
tax_lines?: ShippingMethodTaxLine[] tax_lines?: ShippingMethodTaxLine[]
includes_tax?: boolean
} }
export const simpleShippingMethodFactory = async ( export const simpleShippingMethodFactory = async (
@@ -47,6 +48,7 @@ export const simpleShippingMethodFactory = async (
shipping_option_id: shippingOptionId, shipping_option_id: shippingOptionId,
data: data.data || {}, data: data.data || {},
price: typeof data.price !== "undefined" ? data.price : 500, price: typeof data.price !== "undefined" ? data.price : 500,
includes_tax: data.includes_tax,
}) })
const shippingMethod = await manager.save(toSave) const shippingMethod = await manager.save(toSave)
@@ -8,12 +8,14 @@ import faker from "faker"
import { Connection } from "typeorm" import { Connection } from "typeorm"
export type ShippingOptionFactoryData = { export type ShippingOptionFactoryData = {
id?: string
name?: string name?: string
region_id: string region_id: string
is_return?: boolean is_return?: boolean
is_giftcard?: boolean is_giftcard?: boolean
price?: number price?: number
price_type?: ShippingOptionPriceType price_type?: ShippingOptionPriceType
includes_tax?: boolean
data?: object data?: object
} }
@@ -35,8 +37,8 @@ export const simpleShippingOptionFactory = async (
type: ShippingProfileType.GIFT_CARD, type: ShippingProfileType.GIFT_CARD,
}) })
const created = manager.create(ShippingOption, { const shippingOptionData = {
id: `simple-so-${Math.random() * 1000}`, id: data.id ?? `simple-so-${Math.random() * 1000}`,
name: data.name || "Test Method", name: data.name || "Test Method",
is_return: data.is_return ?? false, is_return: data.is_return ?? false,
region_id: data.region_id, region_id: data.region_id,
@@ -45,7 +47,15 @@ export const simpleShippingOptionFactory = async (
price_type: data.price_type ?? ShippingOptionPriceType.FLAT_RATE, price_type: data.price_type ?? ShippingOptionPriceType.FLAT_RATE,
data: data.data ?? {}, data: data.data ?? {},
amount: typeof data.price !== "undefined" ? data.price : 500, amount: typeof data.price !== "undefined" ? data.price : 500,
}) }
const option = await manager.save(created)
return option // This is purposefully managed out of the original object for the purpose of separating the data linked to a feature flag
// MEDUSA_FF_TAX_INCLUSIVE_PRICING
const { includes_tax } = data
if (typeof includes_tax !== "undefined") {
shippingOptionData["includes_tax"] = includes_tax
}
const created = manager.create(ShippingOption, shippingOptionData)
return await manager.save(created)
} }
@@ -0,0 +1,52 @@
import { IdMap } from "medusa-test-utils"
import { request } from "../../../../../helpers/test-request"
import { currency, CurrencyServiceMock } from "../../../../../services/__mocks__/currency";
import TaxInclusivePricingFeatureFlag from "../../../../../loaders/feature-flags/tax-inclusive-pricing";
describe("GET /admin/currencies/", () => {
describe("successfully list the currency", () => {
let subject
beforeAll(async () => {
subject = await request(
"GET",
`/admin/currencies`,
{
adminSession: {
jwt: {
userId: IdMap.getId("admin_user"),
},
},
flags: [TaxInclusivePricingFeatureFlag],
}
)
})
afterAll(() => {
jest.clearAllMocks()
})
it("calls the listAndCount method from the currency service", () => {
expect(CurrencyServiceMock.listAndCount).toHaveBeenCalledTimes(1)
expect(CurrencyServiceMock.listAndCount).toHaveBeenCalledWith(
{},
{
order: {},
select: undefined,
relations: [],
skip: 0,
take: 20
}
)
})
it("returns the expected currencies", () => {
expect(subject.body).toEqual({
currencies: [currency],
offset: 0,
limit: 20,
count: 1,
})
})
})
})
@@ -0,0 +1,48 @@
import { IdMap } from "medusa-test-utils"
import { request } from "../../../../../helpers/test-request"
import { currency, CurrencyServiceMock } from "../../../../../services/__mocks__/currency";
import TaxInclusivePricingFeatureFlag from "../../../../../loaders/feature-flags/tax-inclusive-pricing";
describe("POST /admin/currencies/:code", () => {
let subject
const code = IdMap.getId("currency-1")
beforeAll(async () => {
subject = await request(
"POST",
`/admin/currencies/${code}`,
{
payload: {
includes_tax: true,
},
adminSession: {
jwt: {
userId: IdMap.getId("admin_user"),
},
},
flags: [TaxInclusivePricingFeatureFlag],
}
)
})
it("returns 200", () => {
expect(subject.status).toEqual(200)
})
it("returns updated currency", () => {
expect(subject.body.currency).toEqual({
...currency,
includes_tax: true,
})
})
it("calls service update", () => {
expect(CurrencyServiceMock.update).toHaveBeenCalledTimes(1)
expect(CurrencyServiceMock.update).toHaveBeenCalledWith(
code,
{
includes_tax: true,
}
)
})
})
@@ -0,0 +1,37 @@
import { Router } from "express"
import middlewares, {
transformBody,
transformQuery,
} from "../../../middlewares"
import { AdminGetCurrenciesParams } from "./list-currencies"
import { AdminPostCurrenciesCurrencyReq } from "./update-currency"
import { isFeatureFlagEnabled } from "../../../middlewares/feature-flag-enabled"
import TaxInclusivePricingFeatureFlag from "../../../../loaders/feature-flags/tax-inclusive-pricing"
export default (app) => {
const route = Router()
app.use(
"/currencies",
isFeatureFlagEnabled(TaxInclusivePricingFeatureFlag.key),
route
)
route.get(
"/",
transformQuery(AdminGetCurrenciesParams, {
isList: true,
}),
middlewares.wrap(require("./list-currencies").default)
)
route.post(
"/:code",
transformBody(AdminPostCurrenciesCurrencyReq),
middlewares.wrap(require("./update-currency").default)
)
return app
}
export * from "./list-currencies"
export * from "./update-currency"
@@ -0,0 +1,76 @@
import { IsBoolean, IsOptional, IsString } from "class-validator"
import { Currency } from "../../../../models"
import { CurrencyService } from "../../../../services"
import { ExtendedRequest } from "../../../../types/global"
import { FindConfig, FindPaginationParams } from "../../../../types/common"
/**
* @oas [get] /currencies
* operationId: "GetCurrencies"
* summary: "List Currency"
* description: "Retrieves a list of Currency"
* x-authenticated: true
* parameters:
* - (query) code {string} Code of the currency to search for.
* - (query) includes_tax {boolean} Search for tax inclusive currencies.
* - (query) order {string} to retrieve products in.
* - (query) offset {string} How many products to skip in the result.
* - (query) limit {string} Limit the number of products returned.
* tags:
* - Currency
* responses:
* 200:
* description: OK
* content:
* application/json:
* schema:
* properties:
* count:
* description: The number of Currency.
* type: integer
* offset:
* description: The offset of the Currency query.
* type: integer
* limit:
* description: The limit of the currency query.
* type: integer
* currencies:
* type: array
* items:
* $ref: "#/components/schemas/currency"
*/
export default async (req: ExtendedRequest<Currency>, res) => {
const currencyService: CurrencyService = req.scope.resolve("currencyService")
const { skip, take } = req.listConfig
req.listConfig.select = undefined
if (req.listConfig.order && req.listConfig.order["created_at"]) {
delete req.listConfig.order["created_at"]
}
const [currencies, count] = await currencyService.listAndCount(
req.filterableFields,
req.listConfig
)
res.json({
currencies,
count,
offset: skip,
limit: take,
})
}
export class AdminGetCurrenciesParams extends FindPaginationParams {
@IsString()
@IsOptional()
code?: string
@IsBoolean()
@IsOptional()
includes_tax?: boolean
@IsString()
@IsOptional()
order?: string
}
@@ -0,0 +1,52 @@
import { IsBoolean, IsOptional } from "class-validator"
import { Currency } from "../../../../models"
import { ExtendedRequest } from "../../../../types/global"
import { CurrencyService } from "../../../../services"
import { FeatureFlagDecorators } from "../../../../utils/feature-flag-decorators"
import TaxInclusivePricingFeatureFlag from "../../../../loaders/feature-flags/tax-inclusive-pricing"
/**
* @oas [post] /currencies/:code
* operationId: "PostCurrenciesCurrency"
* summary: "Update a Currency"
* description: "Update a Currency"
* x-authenticated: true
* parameters:
* - (path) code=* {string} The code of the Currency.
* requestBody:
* content:
* application/json:
* schema:
* properties:
* includes_tax:
* type: boolean
* description: [EXPERIMENTAL] Tax included in prices of currency.
* tags:
* - Currency
* responses:
* 200:
* description: OK
* content:
* application/json:
* schema:
* properties:
* currency:
* $ref: "#/components/schemas/currency"
*/
export default async (req: ExtendedRequest<Currency>, res) => {
const code = req.params.code as string
const data = req.validatedBody as AdminPostCurrenciesCurrencyReq
const currencyService: CurrencyService = req.scope.resolve("currencyService")
const currency = await currencyService.update(code, data)
res.json({ currency })
}
export class AdminPostCurrenciesCurrencyReq {
@FeatureFlagDecorators(TaxInclusivePricingFeatureFlag.key, [
IsOptional(),
IsBoolean(),
])
includes_tax?: boolean
}
@@ -3,7 +3,9 @@ import { Router } from "express"
import middlewares from "../../middlewares" import middlewares from "../../middlewares"
import appRoutes from "./apps" import appRoutes from "./apps"
import authRoutes from "./auth" import authRoutes from "./auth"
import batchRoutes from "./batch"
import collectionRoutes from "./collections" import collectionRoutes from "./collections"
import currencyRoutes from "./currencies"
import customerGroupRoutes from "./customer-groups" import customerGroupRoutes from "./customer-groups"
import customerRoutes from "./customers" import customerRoutes from "./customers"
import discountRoutes from "./discounts" import discountRoutes from "./discounts"
@@ -14,7 +16,6 @@ import noteRoutes from "./notes"
import notificationRoutes from "./notifications" import notificationRoutes from "./notifications"
import orderRoutes from "./orders" import orderRoutes from "./orders"
import priceListRoutes from "./price-lists" import priceListRoutes from "./price-lists"
import batchRoutes from "./batch"
import productTagRoutes from "./product-tags" import productTagRoutes from "./product-tags"
import productTypesRoutes from "./product-types" import productTypesRoutes from "./product-types"
import productRoutes from "./products" import productRoutes from "./products"
@@ -70,6 +71,7 @@ export default (app, container, config) => {
collectionRoutes(route) collectionRoutes(route)
customerGroupRoutes(route) customerGroupRoutes(route)
customerRoutes(route) customerRoutes(route)
currencyRoutes(route)
discountRoutes(route) discountRoutes(route)
draftOrderRoutes(route) draftOrderRoutes(route)
giftCardRoutes(route) giftCardRoutes(route)
@@ -77,15 +79,15 @@ export default (app, container, config) => {
noteRoutes(route) noteRoutes(route)
notificationRoutes(route) notificationRoutes(route)
orderRoutes(route, featureFlagRouter) orderRoutes(route, featureFlagRouter)
priceListRoutes(route) priceListRoutes(route, featureFlagRouter)
productRoutes(route, featureFlagRouter) productRoutes(route, featureFlagRouter)
productTagRoutes(route) productTagRoutes(route)
productTypesRoutes(route) productTypesRoutes(route)
regionRoutes(route) regionRoutes(route, featureFlagRouter)
returnReasonRoutes(route) returnReasonRoutes(route)
returnRoutes(route) returnRoutes(route)
salesChannelRoutes(route) salesChannelRoutes(route)
shippingOptionRoutes(route) shippingOptionRoutes(route, featureFlagRouter)
shippingProfileRoutes(route) shippingProfileRoutes(route)
storeRoutes(route) storeRoutes(route)
swapRoutes(route) swapRoutes(route)
@@ -1,21 +1,24 @@
import {
IsArray,
IsBoolean,
IsEnum,
IsOptional,
IsString,
ValidateNested,
} from "class-validator"
import { import {
AdminPriceListPricesCreateReq, AdminPriceListPricesCreateReq,
CreatePriceListInput, CreatePriceListInput,
PriceListStatus, PriceListStatus,
PriceListType, PriceListType,
} from "../../../../types/price-list" } from "../../../../types/price-list"
import {
IsArray,
IsEnum,
IsOptional,
IsString,
ValidateNested,
} from "class-validator"
import { EntityManager } from "typeorm"
import PriceListService from "../../../../services/price-list"
import { Request } from "express"
import { Type } from "class-transformer" import { Type } from "class-transformer"
import { Request } from "express"
import { EntityManager } from "typeorm"
import TaxInclusivePricingFeatureFlag from "../../../../loaders/feature-flags/tax-inclusive-pricing"
import PriceListService from "../../../../services/price-list"
import { FeatureFlagDecorators } from "../../../../utils/feature-flag-decorators"
/** /**
* @oas [post] /price-lists * @oas [post] /price-lists
@@ -98,6 +101,9 @@ import { Type } from "class-transformer"
* id: * id:
* description: The ID of a customer group * description: The ID of a customer group
* type: string * type: string
* includes_tax:
* description: "[EXPERIMENTAL] Tax included in prices of price list"
* type: boolean
* x-codeSamples: * x-codeSamples:
* - lang: JavaScript * - lang: JavaScript
* label: JS Client * label: JS Client
@@ -215,4 +221,10 @@ export class AdminPostPriceListsPriceListReq {
@Type(() => CustomerGroup) @Type(() => CustomerGroup)
@ValidateNested({ each: true }) @ValidateNested({ each: true })
customer_groups?: CustomerGroup[] customer_groups?: CustomerGroup[]
@FeatureFlagDecorators(TaxInclusivePricingFeatureFlag.key, [
IsOptional(),
IsBoolean(),
])
includes_tax?: boolean
} }
@@ -14,12 +14,18 @@ import {
defaultAdminProductRelations, defaultAdminProductRelations,
} from "../products" } from "../products"
import { AdminPostPriceListsPriceListReq } from "./create-price-list" import { AdminPostPriceListsPriceListReq } from "./create-price-list"
import { FlagRouter } from "../../../../utils/flag-router"
import TaxInclusivePricingFeatureFlag from "../../../../loaders/feature-flags/tax-inclusive-pricing"
const route = Router() const route = Router()
export default (app) => { export default (app, featureFlagRouter: FlagRouter) => {
app.use("/price-lists", route) app.use("/price-lists", route)
if (featureFlagRouter.isFeatureEnabled(TaxInclusivePricingFeatureFlag.key)) {
defaultAdminPriceListFields.push("includes_tax")
}
route.get("/:id", middlewares.wrap(require("./get-price-list").default)) route.get("/:id", middlewares.wrap(require("./get-price-list").default))
route.get( route.get(
@@ -1,22 +1,25 @@
import {
AdminPriceListPricesUpdateReq,
PriceListStatus,
PriceListType,
} from "../../../../types/price-list"
import { import {
IsArray, IsArray,
IsBoolean,
IsEnum, IsEnum,
IsOptional, IsOptional,
IsString, IsString,
ValidateNested, ValidateNested,
} from "class-validator" } from "class-validator"
import { defaultAdminPriceListFields, defaultAdminPriceListRelations } from "." import { defaultAdminPriceListFields, defaultAdminPriceListRelations } from "."
import {
AdminPriceListPricesUpdateReq,
PriceListStatus,
PriceListType,
} from "../../../../types/price-list"
import { PriceList } from "../../../.."
import PriceListService from "../../../../services/price-list"
import { Type } from "class-transformer" import { Type } from "class-transformer"
import { validator } from "../../../../utils/validator"
import { EntityManager } from "typeorm" import { EntityManager } from "typeorm"
import { PriceList } from "../../../.."
import TaxInclusivePricingFeatureFlag from "../../../../loaders/feature-flags/tax-inclusive-pricing"
import PriceListService from "../../../../services/price-list"
import { FeatureFlagDecorators } from "../../../../utils/feature-flag-decorators"
import { validator } from "../../../../utils/validator"
/** /**
* @oas [post] /price-lists/{id} * @oas [post] /price-lists/{id}
@@ -99,6 +102,9 @@ import { EntityManager } from "typeorm"
* id: * id:
* description: The ID of a customer group * description: The ID of a customer group
* type: string * type: string
* includes_tax:
* description: "[EXPERIMENTAL] Tax included in prices of price list"
* type: boolean
* x-codeSamples: * x-codeSamples:
* - lang: JavaScript * - lang: JavaScript
* label: JS Client * label: JS Client
@@ -213,4 +219,10 @@ export class AdminPostPriceListsPriceListPriceListReq {
@Type(() => CustomerGroup) @Type(() => CustomerGroup)
@ValidateNested({ each: true }) @ValidateNested({ each: true })
customer_groups?: CustomerGroup[] customer_groups?: CustomerGroup[]
@FeatureFlagDecorators(TaxInclusivePricingFeatureFlag.key, [
IsOptional(),
IsBoolean(),
])
includes_tax?: boolean
} }
@@ -1,16 +1,18 @@
import { import {
IsArray, IsArray,
IsBoolean,
IsNumber, IsNumber,
IsObject, IsObject,
IsOptional, IsOptional,
IsString, IsString,
} from "class-validator" } from "class-validator"
import { EntityManager } from "typeorm" import { EntityManager } from "typeorm"
import { defaultAdminRegionFields, defaultAdminRegionRelations } from "."
import { validator } from "../../../../utils/validator"
import { Region } from "../../../.." import { Region } from "../../../.."
import TaxInclusivePricingFeatureFlag from "../../../../loaders/feature-flags/tax-inclusive-pricing"
import RegionService from "../../../../services/region" import RegionService from "../../../../services/region"
import { defaultAdminRegionRelations, defaultAdminRegionFields } from "." import { FeatureFlagDecorators } from "../../../../utils/feature-flag-decorators"
import { validator } from "../../../../utils/validator"
/** /**
* @oas [post] /regions * @oas [post] /regions
@@ -61,6 +63,9 @@ import { defaultAdminRegionRelations, defaultAdminRegionFields } from "."
* type: array * type: array
* items: * items:
* type: string * type: string
* includes_tax:
* description: "[EXPERIMENTAL] Tax included in prices of region"
* type: boolean
* x-codeSamples: * x-codeSamples:
* - lang: JavaScript * - lang: JavaScript
* label: JS Client * label: JS Client
@@ -179,6 +184,12 @@ export class AdminPostRegionsReq {
@IsString({ each: true }) @IsString({ each: true })
countries: string[] countries: string[]
@FeatureFlagDecorators(TaxInclusivePricingFeatureFlag.key, [
IsOptional(),
IsBoolean(),
])
includes_tax?: boolean
@IsObject() @IsObject()
@IsOptional() @IsOptional()
metadata?: Record<string, unknown> metadata?: Record<string, unknown>
@@ -3,12 +3,18 @@ import { Region } from "../../../.."
import { DeleteResponse, PaginatedResponse } from "../../../../types/common" import { DeleteResponse, PaginatedResponse } from "../../../../types/common"
import middlewares from "../../../middlewares" import middlewares from "../../../middlewares"
import "reflect-metadata" import "reflect-metadata"
import { FlagRouter } from "../../../../utils/flag-router"
import TaxInclusivePricingFeatureFlag from "../../../../loaders/feature-flags/tax-inclusive-pricing"
const route = Router() const route = Router()
export default (app) => { export default (app, featureFlagRouter: FlagRouter) => {
app.use("/regions", route) app.use("/regions", route)
if (featureFlagRouter.isFeatureEnabled(TaxInclusivePricingFeatureFlag.key)) {
defaultAdminRegionFields.push("includes_tax")
}
route.get("/", middlewares.wrap(require("./list-regions").default)) route.get("/", middlewares.wrap(require("./list-regions").default))
route.get("/:region_id", middlewares.wrap(require("./get-region").default)) route.get("/:region_id", middlewares.wrap(require("./get-region").default))
@@ -8,9 +8,11 @@ import {
} from "class-validator" } from "class-validator"
import { EntityManager } from "typeorm" import { EntityManager } from "typeorm"
import { validator } from "../../../../utils/validator" import { defaultAdminRegionFields, defaultAdminRegionRelations } from "."
import TaxInclusivePricingFeatureFlag from "../../../../loaders/feature-flags/tax-inclusive-pricing"
import RegionService from "../../../../services/region" import RegionService from "../../../../services/region"
import { defaultAdminRegionRelations, defaultAdminRegionFields } from "." import { FeatureFlagDecorators } from "../../../../utils/feature-flag-decorators"
import { validator } from "../../../../utils/validator"
/** /**
* @oas [post] /regions/{id} * @oas [post] /regions/{id}
@@ -49,6 +51,9 @@ import { defaultAdminRegionRelations, defaultAdminRegionFields } from "."
* tax_rate: * tax_rate:
* description: "The tax rate to use on Orders in the Region." * description: "The tax rate to use on Orders in the Region."
* type: number * type: number
* includes_tax:
* description: "[EXPERIMENTAL] Tax included in prices of region"
* type: boolean
* payment_providers: * payment_providers:
* description: "A list of Payment Provider IDs that should be enabled for the Region" * description: "A list of Payment Provider IDs that should be enabled for the Region"
* type: array * type: array
@@ -178,6 +183,12 @@ export class AdminPostRegionsRegionReq {
@IsOptional() @IsOptional()
countries?: string[] countries?: string[]
@FeatureFlagDecorators(TaxInclusivePricingFeatureFlag.key, [
IsOptional(),
IsBoolean(),
])
includes_tax?: boolean
@IsObject() @IsObject()
@IsOptional() @IsOptional()
metadata?: Record<string, unknown> metadata?: Record<string, unknown>
@@ -1,6 +1,7 @@
import { IdMap } from "medusa-test-utils" import { IdMap } from "medusa-test-utils"
import { request } from "../../../../../helpers/test-request" import { request } from "../../../../../helpers/test-request"
import { SalesChannelServiceMock } from "../../../../../services/__mocks__/sales-channel" import { SalesChannelServiceMock } from "../../../../../services/__mocks__/sales-channel"
import SalesChannelFeatureFlag from "../../../../../loaders/feature-flags/sales-channels";
describe("POST /admin/sales-channels/:id/products/batch", () => { describe("POST /admin/sales-channels/:id/products/batch", () => {
describe("add product to a sales channel", () => { describe("add product to a sales channel", () => {
@@ -21,7 +22,7 @@ describe("POST /admin/sales-channels/:id/products/batch", () => {
payload: { payload: {
product_ids: [{ id: "sales_channel_1_product_1" }], product_ids: [{ id: "sales_channel_1_product_1" }],
}, },
flags: ["sales_channels"], flags: [SalesChannelFeatureFlag],
} }
) )
}) })
@@ -2,6 +2,7 @@ import { IdMap } from "medusa-test-utils"
import { request } from "../../../../../helpers/test-request" import { request } from "../../../../../helpers/test-request"
import { SalesChannelServiceMock } from "../../../../../services/__mocks__/sales-channel" import { SalesChannelServiceMock } from "../../../../../services/__mocks__/sales-channel"
import SalesChannelFeatureFlag from "../../../../../loaders/feature-flags/sales-channels";
describe("POST /admin/sales-channels", () => { describe("POST /admin/sales-channels", () => {
describe("successfully get a sales channel", () => { describe("successfully get a sales channel", () => {
@@ -18,7 +19,7 @@ describe("POST /admin/sales-channels", () => {
name: "sales channel 1 name", name: "sales channel 1 name",
description: "sales channel 1 description", description: "sales channel 1 description",
}, },
flags: ["sales_channels"], flags: [SalesChannelFeatureFlag],
}) })
}) })
@@ -1,6 +1,7 @@
import { IdMap } from "medusa-test-utils" import { IdMap } from "medusa-test-utils"
import { request } from "../../../../../helpers/test-request" import { request } from "../../../../../helpers/test-request"
import { SalesChannelServiceMock } from "../../../../../services/__mocks__/sales-channel" import { SalesChannelServiceMock } from "../../../../../services/__mocks__/sales-channel"
import SalesChannelFeatureFlag from "../../../../../loaders/feature-flags/sales-channels";
describe("DELETE /admin/sales-channels/:id/products/batch", () => { describe("DELETE /admin/sales-channels/:id/products/batch", () => {
describe("remove product from a sales channel", () => { describe("remove product from a sales channel", () => {
@@ -19,7 +20,7 @@ describe("DELETE /admin/sales-channels/:id/products/batch", () => {
payload: { payload: {
product_ids: [{ id: IdMap.getId("sales_channel_1_product_1") }] product_ids: [{ id: IdMap.getId("sales_channel_1_product_1") }]
}, },
flags: ["sales_channels"], flags: [SalesChannelFeatureFlag],
} }
) )
}) })
@@ -1,6 +1,7 @@
import { IdMap } from "medusa-test-utils" import { IdMap } from "medusa-test-utils"
import { request } from "../../../../../helpers/test-request" import { request } from "../../../../../helpers/test-request"
import { SalesChannelServiceMock } from "../../../../../services/__mocks__/sales-channel" import { SalesChannelServiceMock } from "../../../../../services/__mocks__/sales-channel"
import SalesChannelFeatureFlag from "../../../../../loaders/feature-flags/sales-channels";
describe("DELETE /admin/sales-channels/:id", () => { describe("DELETE /admin/sales-channels/:id", () => {
describe("successfully delete a sales channel", () => { describe("successfully delete a sales channel", () => {
@@ -16,7 +17,7 @@ describe("DELETE /admin/sales-channels/:id", () => {
userId: IdMap.getId("admin_user"), userId: IdMap.getId("admin_user"),
}, },
}, },
flags: ["sales_channels"], flags: [SalesChannelFeatureFlag],
} }
) )
}) })
@@ -1,6 +1,7 @@
import { IdMap } from "medusa-test-utils" import { IdMap } from "medusa-test-utils"
import { request } from "../../../../../helpers/test-request" import { request } from "../../../../../helpers/test-request"
import { SalesChannelServiceMock } from "../../../../../services/__mocks__/sales-channel" import { SalesChannelServiceMock } from "../../../../../services/__mocks__/sales-channel"
import SalesChannelFeatureFlag from "../../../../../loaders/feature-flags/sales-channels";
describe("GET /admin/sales-channels/:id", () => { describe("GET /admin/sales-channels/:id", () => {
describe("successfully get a sales channel", () => { describe("successfully get a sales channel", () => {
@@ -16,7 +17,7 @@ describe("GET /admin/sales-channels/:id", () => {
userId: IdMap.getId("admin_user"), userId: IdMap.getId("admin_user"),
}, },
}, },
flags: ["sales_channels"], flags: [SalesChannelFeatureFlag],
} }
) )
}) })
@@ -1,6 +1,7 @@
import { IdMap } from "medusa-test-utils" import { IdMap } from "medusa-test-utils"
import { request } from "../../../../../helpers/test-request" import { request } from "../../../../../helpers/test-request"
import { SalesChannelServiceMock } from "../../../../../services/__mocks__/sales-channel" import { SalesChannelServiceMock } from "../../../../../services/__mocks__/sales-channel"
import SalesChannelFeatureFlag from "../../../../../loaders/feature-flags/sales-channels";
describe("GET /admin/sales-channels/", () => { describe("GET /admin/sales-channels/", () => {
describe("successfully list the sales channel", () => { describe("successfully list the sales channel", () => {
@@ -16,7 +17,7 @@ describe("GET /admin/sales-channels/", () => {
userId: IdMap.getId("admin_user"), userId: IdMap.getId("admin_user"),
}, },
}, },
flags: ["sales_channels"], flags: [SalesChannelFeatureFlag],
} }
) )
}) })
@@ -1,6 +1,7 @@
import { IdMap } from "medusa-test-utils" import { IdMap } from "medusa-test-utils"
import { request } from "../../../../../helpers/test-request" import { request } from "../../../../../helpers/test-request"
import { SalesChannelServiceMock } from "../../../../../services/__mocks__/sales-channel" import { SalesChannelServiceMock } from "../../../../../services/__mocks__/sales-channel"
import SalesChannelFeatureFlag from "../../../../../loaders/feature-flags/sales-channels";
describe("POST /admin/regions/:region_id/countries", () => { describe("POST /admin/regions/:region_id/countries", () => {
describe("successful creation", () => { describe("successful creation", () => {
@@ -18,7 +19,7 @@ describe("POST /admin/regions/:region_id/countries", () => {
userId: IdMap.getId("admin_user"), userId: IdMap.getId("admin_user"),
}, },
}, },
flags: ["sales_channels"], flags: [SalesChannelFeatureFlag],
}) })
}) })
@@ -1,5 +1,6 @@
import { import {
IsArray, IsArray,
IsBoolean,
IsNumber, IsNumber,
IsObject, IsObject,
IsOptional, IsOptional,
@@ -9,8 +10,10 @@ import {
import { defaultFields, defaultRelations } from "." import { defaultFields, defaultRelations } from "."
import { Type } from "class-transformer" import { Type } from "class-transformer"
import { validator } from "../../../../utils/validator"
import { EntityManager } from "typeorm" import { EntityManager } from "typeorm"
import TaxInclusivePricingFeatureFlag from "../../../../loaders/feature-flags/tax-inclusive-pricing"
import { FeatureFlagDecorators } from "../../../../utils/feature-flag-decorators"
import { validator } from "../../../../utils/validator"
/** /**
* @oas [post] /shipping-options * @oas [post] /shipping-options
@@ -81,6 +84,9 @@ import { EntityManager } from "typeorm"
* metadata: * metadata:
* description: An optional set of key-value pairs with additional information. * description: An optional set of key-value pairs with additional information.
* type: object * type: object
* includes_tax:
* description: "[EXPERIMENTAL] Tax included in prices of shipping option"
* type: boolean
* x-codeSamples: * x-codeSamples:
* - lang: JavaScript * - lang: JavaScript
* label: JS Client * label: JS Client
@@ -214,4 +220,10 @@ export class AdminPostShippingOptionsReq {
@IsObject() @IsObject()
@IsOptional() @IsOptional()
metadata?: object metadata?: object
@FeatureFlagDecorators(TaxInclusivePricingFeatureFlag.key, [
IsOptional(),
IsBoolean(),
])
includes_tax?: boolean
} }
@@ -2,12 +2,18 @@ import { Router } from "express"
import { ShippingOption } from "../../../.." import { ShippingOption } from "../../../.."
import { PaginatedResponse, DeleteResponse } from "../../../../types/common" import { PaginatedResponse, DeleteResponse } from "../../../../types/common"
import middlewares from "../../../middlewares" import middlewares from "../../../middlewares"
import { FlagRouter } from "../../../../utils/flag-router"
import TaxInclusivePricingFeatureFlag from "../../../../loaders/feature-flags/tax-inclusive-pricing"
const route = Router() const route = Router()
export default (app) => { export default (app, featureFlagRouter: FlagRouter) => {
app.use("/shipping-options", route) app.use("/shipping-options", route)
if (featureFlagRouter.isFeatureEnabled(TaxInclusivePricingFeatureFlag.key)) {
defaultFields.push("includes_tax")
}
route.get("/", middlewares.wrap(require("./list-shipping-options").default)) route.get("/", middlewares.wrap(require("./list-shipping-options").default))
route.post("/", middlewares.wrap(require("./create-shipping-option").default)) route.post("/", middlewares.wrap(require("./create-shipping-option").default))
@@ -10,8 +10,10 @@ import {
import { defaultFields, defaultRelations } from "." import { defaultFields, defaultRelations } from "."
import { Type } from "class-transformer" import { Type } from "class-transformer"
import { validator } from "../../../../utils/validator"
import { EntityManager } from "typeorm" import { EntityManager } from "typeorm"
import TaxInclusivePricingFeatureFlag from "../../../../loaders/feature-flags/tax-inclusive-pricing"
import { FeatureFlagDecorators } from "../../../../utils/feature-flag-decorators"
import { validator } from "../../../../utils/validator"
/** /**
* @oas [post] /shipping-options/{id} * @oas [post] /shipping-options/{id}
@@ -60,6 +62,9 @@ import { EntityManager } from "typeorm"
* amount: * amount:
* description: The amount to compare with. * description: The amount to compare with.
* type: integer * type: integer
* includes_tax:
* description: "[EXPERIMENTAL] Tax included in prices of shipping option"
* type: boolean
* x-codeSamples: * x-codeSamples:
* - lang: JavaScript * - lang: JavaScript
* label: JS Client * label: JS Client
@@ -174,4 +179,10 @@ export class AdminPostShippingOptionsOptionReq {
@IsObject() @IsObject()
@IsOptional() @IsOptional()
metadata?: object metadata?: object
@FeatureFlagDecorators(TaxInclusivePricingFeatureFlag.key, [
IsOptional(),
IsBoolean(),
])
includes_tax?: boolean
} }
+4 -5
View File
@@ -7,9 +7,10 @@ import supertest from "supertest"
import querystring from "querystring" import querystring from "querystring"
import apiLoader from "../loaders/api" import apiLoader from "../loaders/api"
import passportLoader from "../loaders/passport" import passportLoader from "../loaders/passport"
import featureFlagLoader from "../loaders/feature-flags" import featureFlagLoader, { featureFlagRouter } from "../loaders/feature-flags"
import servicesLoader from "../loaders/services" import servicesLoader from "../loaders/services"
import strategiesLoader from "../loaders/strategies" import strategiesLoader from "../loaders/strategies"
import logger from "../loaders/logger";
const adminSessionOpts = { const adminSessionOpts = {
cookieName: "session", cookieName: "session",
@@ -36,8 +37,6 @@ const testApp = express()
const container = createContainer() const container = createContainer()
const featureFlagRouter = featureFlagLoader(config)
container.register("featureFlagRouter", asValue(featureFlagRouter)) container.register("featureFlagRouter", asValue(featureFlagRouter))
container.register("configModule", asValue(config)) container.register("configModule", asValue(config))
container.register({ container.register({
@@ -60,6 +59,7 @@ testApp.use((req, res, next) => {
next() next()
}) })
featureFlagLoader(config)
servicesLoader({ container, configModule: config }) servicesLoader({ container, configModule: config })
strategiesLoader({ container, configModule: config }) strategiesLoader({ container, configModule: config })
passportLoader({ app: testApp, container, configModule: config }) passportLoader({ app: testApp, container, configModule: config })
@@ -77,7 +77,7 @@ export async function request(method, url, opts = {}) {
const { payload, query, headers = {}, flags = [] } = opts const { payload, query, headers = {}, flags = [] } = opts
flags.forEach((flag) => { flags.forEach((flag) => {
featureFlagRouter.setFlag(flag, true) featureFlagRouter.setFlag(flag.key, true)
}) })
const queryParams = query && querystring.stringify(query) const queryParams = query && querystring.stringify(query)
@@ -148,6 +148,5 @@ export async function request(method, url, opts = {}) {
// c[clientSessionOpts.cookieName] && // c[clientSessionOpts.cookieName] &&
// sessions.util.decode(clientSessionOpts, c[clientSessionOpts.cookieName]) // sessions.util.decode(clientSessionOpts, c[clientSessionOpts.cookieName])
// .content // .content
return res return res
} }
@@ -1,6 +1,7 @@
import { EntityManager } from "typeorm" import { EntityManager } from "typeorm"
import { MoneyAmount } from ".." import { MoneyAmount } from ".."
import { PriceListType } from "../types/price-list" import { PriceListType } from "../types/price-list"
import { TaxServiceRate } from "../types/tax-service"
export interface IPriceSelectionStrategy { export interface IPriceSelectionStrategy {
/** /**
@@ -55,6 +56,7 @@ export type PriceSelectionContext = {
region_id?: string region_id?: string
currency_code?: string currency_code?: string
include_discount_prices?: boolean include_discount_prices?: boolean
tax_rates?: TaxServiceRate[]
} }
enum DefaultPriceType { enum DefaultPriceType {
@@ -67,7 +69,9 @@ export const PriceType = { ...DefaultPriceType, ...PriceListType }
export type PriceSelectionResult = { export type PriceSelectionResult = {
originalPrice: number | null originalPrice: number | null
originalPriceIncludesTax?: boolean | null
calculatedPrice: number | null calculatedPrice: number | null
calculatedPriceIncludesTax?: boolean | null
calculatedPriceType?: PriceType calculatedPriceType?: PriceType
prices: MoneyAmount[] // prices is an array of all possible price for the input customer and region prices prices: MoneyAmount[] // prices is an array of all possible price for the input customer and region prices
} }
@@ -14,6 +14,8 @@ const isTruthy = (val: string | boolean | undefined): boolean => {
return !!val return !!val
} }
export const featureFlagRouter = new FlagRouter({})
export default ( export default (
configModule: { featureFlags?: Record<string, string | boolean> } = {}, configModule: { featureFlags?: Record<string, string | boolean> } = {},
logger?: Logger, logger?: Logger,
@@ -60,5 +62,9 @@ export default (
} }
} }
return new FlagRouter(flagConfig) for (const flag of Object.keys(flagConfig)) {
featureFlagRouter.setFlag(flag, flagConfig[flag])
}
return featureFlagRouter
} }
@@ -0,0 +1,10 @@
import { FlagSettings } from "../../types/feature-flags"
const TaxInclusivePricingFeatureFlag: FlagSettings = {
key: "tax_inclusive_pricing",
default_val: false,
env_key: "MEDUSA_FF_TAX_INCLUSIVE_PRICING",
description: "[WIP] Enable tax inclusive pricing",
}
export default TaxInclusivePricingFeatureFlag
@@ -0,0 +1,46 @@
import { MigrationInterface, QueryRunner } from "typeorm"
import TaxInclusivePricingFlag from "../loaders/feature-flags/tax-inclusive-pricing"
export const featureFlag = TaxInclusivePricingFlag.key
export class test1659501357661 implements MigrationInterface {
name = "test1659501357661"
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(
`ALTER TABLE "currency" ADD "includes_tax" boolean NOT NULL DEFAULT false`
)
await queryRunner.query(
`ALTER TABLE "region" ADD "includes_tax" boolean NOT NULL DEFAULT false`
)
await queryRunner.query(
`ALTER TABLE "shipping_option" ADD "includes_tax" boolean NOT NULL DEFAULT false`
)
await queryRunner.query(
`ALTER TABLE "price_list" ADD "includes_tax" boolean NOT NULL DEFAULT false`
)
await queryRunner.query(
`ALTER TABLE "shipping_method" ADD "includes_tax" boolean NOT NULL DEFAULT false`
)
await queryRunner.query(
`ALTER TABLE "line_item" ADD "includes_tax" boolean NOT NULL DEFAULT false`
)
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(
`ALTER TABLE "line_item" DROP COLUMN "includes_tax"`
)
await queryRunner.query(
`ALTER TABLE "shipping_method" DROP COLUMN "includes_tax"`
)
await queryRunner.query(
`ALTER TABLE "price_list" DROP COLUMN "includes_tax"`
)
await queryRunner.query(
`ALTER TABLE "shipping_option" DROP COLUMN "includes_tax"`
)
await queryRunner.query(`ALTER TABLE "region" DROP COLUMN "includes_tax"`)
await queryRunner.query(`ALTER TABLE "currency" DROP COLUMN "includes_tax"`)
}
}
+8
View File
@@ -1,4 +1,6 @@
import { Column, Entity, PrimaryColumn } from "typeorm" import { Column, Entity, PrimaryColumn } from "typeorm"
import { FeatureFlagColumn } from "../utils/feature-flag-decorators"
import TaxInclusivePricingFeatureFlag from "../loaders/feature-flags/tax-inclusive-pricing"
@Entity() @Entity()
export class Currency { export class Currency {
@@ -13,6 +15,9 @@ export class Currency {
@Column() @Column()
name: string name: string
@FeatureFlagColumn(TaxInclusivePricingFeatureFlag.key, { default: false })
includes_tax?: boolean
} }
/** /**
@@ -45,4 +50,7 @@ export class Currency {
* description: "The written name of the currency" * description: "The written name of the currency"
* type: string * type: string
* example: US Dollar * example: US Dollar
* includes_tax:
* description: "[EXPERIMENTAL] Does the currency prices include tax"
* type: boolean
*/ */
@@ -92,4 +92,7 @@ export class CustomShippingOption extends SoftDeletableEntity {
* type: object * type: object
* description: An optional key-value map with additional details * description: An optional key-value map with additional details
* example: {car: "white"} * example: {car: "white"}
* includes_tax:
* description: "[EXPERIMENTAL] Indicates if the custom shipping option price include tax"
* type: boolean
*/ */
+8
View File
@@ -19,6 +19,8 @@ import { Order } from "./order"
import { ProductVariant } from "./product-variant" import { ProductVariant } from "./product-variant"
import { Swap } from "./swap" import { Swap } from "./swap"
import { generateEntityId } from "../utils/generate-entity-id" import { generateEntityId } from "../utils/generate-entity-id"
import { FeatureFlagColumn } from "../utils/feature-flag-decorators"
import TaxInclusivePricingFeatureFlag from "../loaders/feature-flags/tax-inclusive-pricing"
@Check(`"fulfilled_quantity" <= "quantity"`) @Check(`"fulfilled_quantity" <= "quantity"`)
@Check(`"shipped_quantity" <= "fulfilled_quantity"`) @Check(`"shipped_quantity" <= "fulfilled_quantity"`)
@@ -116,6 +118,9 @@ export class LineItem extends BaseEntity {
@DbAwareColumn({ type: "jsonb", nullable: true }) @DbAwareColumn({ type: "jsonb", nullable: true })
metadata: Record<string, unknown> metadata: Record<string, unknown>
@FeatureFlagColumn(TaxInclusivePricingFeatureFlag.key, { default: false })
includes_tax: boolean
refundable?: number | null refundable?: number | null
subtotal?: number | null subtotal?: number | null
tax_total?: number | null tax_total?: number | null
@@ -275,6 +280,9 @@ export class LineItem extends BaseEntity {
* type: integer * type: integer
* description: The total of the gift card of the line item * description: The total of the gift card of the line item
* example: 0 * example: 0
* includes_tax:
* description: "[EXPERIMENTAL] Indicates if the line item unit_price include tax"
* type: boolean
* created_at: * created_at:
* type: string * type: string
* description: "The date with timezone at which the resource was created." * description: "The date with timezone at which the resource was created."
+2 -2
View File
@@ -21,7 +21,7 @@ export class MoneyAmount extends SoftDeletableEntity {
@ManyToOne(() => Currency) @ManyToOne(() => Currency)
@JoinColumn({ name: "currency_code", referencedColumnName: "code" }) @JoinColumn({ name: "currency_code", referencedColumnName: "code" })
currency: Currency currency?: Currency
@Column({ type: "int" }) @Column({ type: "int" })
amount: number amount: number
@@ -58,7 +58,7 @@ export class MoneyAmount extends SoftDeletableEntity {
@ManyToOne(() => Region) @ManyToOne(() => Region)
@JoinColumn({ name: "region_id" }) @JoinColumn({ name: "region_id" })
region: Region region?: Region
@BeforeInsert() @BeforeInsert()
private beforeInsert(): undefined | void { private beforeInsert(): undefined | void {
+9
View File
@@ -13,6 +13,8 @@ import { CustomerGroup } from "./customer-group"
import { MoneyAmount } from "./money-amount" import { MoneyAmount } from "./money-amount"
import { SoftDeletableEntity } from "../interfaces/models/soft-deletable-entity" import { SoftDeletableEntity } from "../interfaces/models/soft-deletable-entity"
import { generateEntityId } from "../utils/generate-entity-id" import { generateEntityId } from "../utils/generate-entity-id"
import { FeatureFlagColumn } from "../utils/feature-flag-decorators"
import TaxInclusivePricingFeatureFlag from "../loaders/feature-flags/tax-inclusive-pricing"
@Entity() @Entity()
export class PriceList extends SoftDeletableEntity { export class PriceList extends SoftDeletableEntity {
@@ -58,6 +60,9 @@ export class PriceList extends SoftDeletableEntity {
}) })
prices: MoneyAmount[] prices: MoneyAmount[]
@FeatureFlagColumn(TaxInclusivePricingFeatureFlag.key, { default: false })
includes_tax: boolean
@BeforeInsert() @BeforeInsert()
private beforeInsert(): undefined | void { private beforeInsert(): undefined | void {
this.id = generateEntityId(this.id, "pl") this.id = generateEntityId(this.id, "pl")
@@ -118,6 +123,10 @@ export class PriceList extends SoftDeletableEntity {
* type: array * type: array
* items: * items:
* $ref: "#/components/schemas/money_amount" * $ref: "#/components/schemas/money_amount"
* $ref: "#/components/schemas/customer_group"
* includes_tax:
* description: "[EXPERIMENTAL] Does the price list prices include tax"
* type: boolean
* created_at: * created_at:
* type: string * type: string
* description: "The date with timezone at which the resource was created." * description: "The date with timezone at which the resource was created."
+8
View File
@@ -18,6 +18,8 @@ import { SoftDeletableEntity } from "../interfaces/models/soft-deletable-entity"
import { TaxProvider } from "./tax-provider" import { TaxProvider } from "./tax-provider"
import { TaxRate } from "./tax-rate" import { TaxRate } from "./tax-rate"
import { generateEntityId } from "../utils/generate-entity-id" import { generateEntityId } from "../utils/generate-entity-id"
import { FeatureFlagColumn } from "../utils/feature-flag-decorators"
import TaxInclusivePricingFeatureFlag from "../loaders/feature-flags/tax-inclusive-pricing"
@Entity() @Entity()
export class Region extends SoftDeletableEntity { export class Region extends SoftDeletableEntity {
@@ -93,6 +95,9 @@ export class Region extends SoftDeletableEntity {
@DbAwareColumn({ type: "jsonb", nullable: true }) @DbAwareColumn({ type: "jsonb", nullable: true })
metadata: Record<string, unknown> metadata: Record<string, unknown>
@FeatureFlagColumn(TaxInclusivePricingFeatureFlag.key, { default: false })
includes_tax: boolean
@BeforeInsert() @BeforeInsert()
private beforeInsert(): void { private beforeInsert(): void {
this.id = generateEntityId(this.id, "reg") this.id = generateEntityId(this.id, "reg")
@@ -170,6 +175,9 @@ export class Region extends SoftDeletableEntity {
* type: array * type: array
* items: * items:
* $ref: "#/components/schemas/fulfillment_provider" * $ref: "#/components/schemas/fulfillment_provider"
* includes_tax:
* description: "[EXPERIMENTAL] Does the prices for the region include tax"
* type: boolean
* created_at: * created_at:
* type: string * type: string
* description: "The date with timezone at which the resource was created." * description: "The date with timezone at which the resource was created."
@@ -20,6 +20,8 @@ import { ShippingMethodTaxLine } from "./shipping-method-tax-line"
import { ShippingOption } from "./shipping-option" import { ShippingOption } from "./shipping-option"
import { Swap } from "./swap" import { Swap } from "./swap"
import { generateEntityId } from "../utils/generate-entity-id" import { generateEntityId } from "../utils/generate-entity-id"
import { FeatureFlagColumn } from "../utils/feature-flag-decorators"
import TaxInclusivePricingFeatureFlag from "../loaders/feature-flags/tax-inclusive-pricing"
@Check( @Check(
`"claim_order_id" IS NOT NULL OR "order_id" IS NOT NULL OR "cart_id" IS NOT NULL OR "swap_id" IS NOT NULL OR "return_id" IS NOT NULL` `"claim_order_id" IS NOT NULL OR "order_id" IS NOT NULL OR "cart_id" IS NOT NULL OR "swap_id" IS NOT NULL OR "return_id" IS NOT NULL`
@@ -89,6 +91,9 @@ export class ShippingMethod {
@DbAwareColumn({ type: "jsonb" }) @DbAwareColumn({ type: "jsonb" })
data: Record<string, unknown> data: Record<string, unknown>
@FeatureFlagColumn(TaxInclusivePricingFeatureFlag.key, { default: false })
includes_tax: boolean
@BeforeInsert() @BeforeInsert()
private beforeInsert(): void { private beforeInsert(): void {
this.id = generateEntityId(this.id, "sm") this.id = generateEntityId(this.id, "sm")
@@ -163,4 +168,7 @@ export class ShippingMethod {
* description: "Additional data that the Fulfillment Provider needs to fulfill the shipment. This is used in combination with the Shipping Options data, and may contain information such as a drop point id." * description: "Additional data that the Fulfillment Provider needs to fulfill the shipment. This is used in combination with the Shipping Options data, and may contain information such as a drop point id."
* type: object * type: object
* example: {} * example: {}
* includes_tax:
* description: "[EXPERIMENTAL] Indicates if the shipping method price include tax"
* type: boolean
*/ */
@@ -16,6 +16,8 @@ import { ShippingOptionRequirement } from "./shipping-option-requirement"
import { ShippingProfile } from "./shipping-profile" import { ShippingProfile } from "./shipping-profile"
import { SoftDeletableEntity } from "../interfaces/models/soft-deletable-entity" import { SoftDeletableEntity } from "../interfaces/models/soft-deletable-entity"
import { generateEntityId } from "../utils/generate-entity-id" import { generateEntityId } from "../utils/generate-entity-id"
import { FeatureFlagColumn } from "../utils/feature-flag-decorators"
import TaxInclusivePricingFeatureFlag from "../loaders/feature-flags/tax-inclusive-pricing"
export enum ShippingOptionPriceType { export enum ShippingOptionPriceType {
FLAT_RATE = "flat_rate", FLAT_RATE = "flat_rate",
@@ -75,6 +77,9 @@ export class ShippingOption extends SoftDeletableEntity {
@DbAwareColumn({ type: "jsonb", nullable: true }) @DbAwareColumn({ type: "jsonb", nullable: true })
metadata: Record<string, unknown> metadata: Record<string, unknown>
@FeatureFlagColumn(TaxInclusivePricingFeatureFlag.key, { default: false })
includes_tax: boolean
@BeforeInsert() @BeforeInsert()
private beforeInsert(): void { private beforeInsert(): void {
this.id = generateEntityId(this.id, "so") this.id = generateEntityId(this.id, "so")
@@ -146,6 +151,9 @@ export class ShippingOption extends SoftDeletableEntity {
* description: "The data needed for the Fulfillment Provider to identify the Shipping Option." * description: "The data needed for the Fulfillment Provider to identify the Shipping Option."
* type: object * type: object
* example: {} * example: {}
* includes_tax:
* description: "[EXPERIMENTAL] Does the shipping option price include tax"
* type: boolean
* created_at: * created_at:
* type: string * type: string
* description: "The date with timezone at which the resource was created." * description: "The date with timezone at which the resource was created."
+1 -1
View File
@@ -1,5 +1,5 @@
import { EntityRepository, Repository } from "typeorm" import { EntityRepository, Repository } from "typeorm"
import { Currency } from "../models/currency" import { Currency } from "../models"
@EntityRepository(Currency) @EntityRepository(Currency)
export class CurrencyRepository extends Repository<Currency> { } export class CurrencyRepository extends Repository<Currency> { }
@@ -1,5 +1,4 @@
import partition from "lodash/partition" import partition from "lodash/partition"
import { MedusaError } from "medusa-core-utils"
import { import {
Brackets, Brackets,
EntityRepository, EntityRepository,
@@ -7,6 +6,7 @@ import {
IsNull, IsNull,
Not, Not,
Repository, Repository,
WhereExpressionBuilder,
} from "typeorm" } from "typeorm"
import { MoneyAmount } from "../models/money-amount" import { MoneyAmount } from "../models/money-amount"
import { import {
@@ -126,7 +126,7 @@ export class MoneyAmountRepository extends Repository<MoneyAmount> {
.leftJoinAndSelect("ma.price_list", "price_list") .leftJoinAndSelect("ma.price_list", "price_list")
.where("ma.variant_id = :variant_id", { variant_id }) .where("ma.variant_id = :variant_id", { variant_id })
const getAndWhere = (subQb) => { const getAndWhere = (subQb): WhereExpressionBuilder => {
const andWhere = subQb.where("ma.price_list_id = :price_list_id", { const andWhere = subQb.where("ma.price_list_id = :price_list_id", {
price_list_id, price_list_id,
}) })
@@ -146,7 +146,8 @@ export class MoneyAmountRepository extends Repository<MoneyAmount> {
region_id?: string, region_id?: string,
currency_code?: string, currency_code?: string,
customer_id?: string, customer_id?: string,
include_discount_prices?: boolean include_discount_prices?: boolean,
include_tax_inclusive_pricing = false
): Promise<[MoneyAmount[], number]> { ): Promise<[MoneyAmount[], number]> {
const date = new Date() const date = new Date()
@@ -154,12 +155,9 @@ export class MoneyAmountRepository extends Repository<MoneyAmount> {
.leftJoinAndSelect("ma.price_list", "price_list") .leftJoinAndSelect("ma.price_list", "price_list")
.where({ variant_id: variant_id }) .where({ variant_id: variant_id })
.andWhere("(ma.price_list_id is null or price_list.status = 'active')") .andWhere("(ma.price_list_id is null or price_list.status = 'active')")
.andWhere( .andWhere("(price_list.ends_at is null OR price_list.ends_at > :date)", {
"(price_list.ends_at is null OR price_list.ends_at > :date)",
{
date: date.toUTCString(), date: date.toUTCString(),
} })
)
.andWhere( .andWhere(
"(price_list.starts_at is null OR price_list.starts_at < :date)", "(price_list.starts_at is null OR price_list.starts_at < :date)",
{ {
@@ -167,6 +165,11 @@ export class MoneyAmountRepository extends Repository<MoneyAmount> {
} }
) )
if (include_tax_inclusive_pricing) {
qb.leftJoin("ma.currency", "currency")
.leftJoin("ma.region", "region")
.addSelect(["currency.includes_tax", "region.includes_tax"])
}
if (region_id || currency_code) { if (region_id || currency_code) {
qb.andWhere( qb.andWhere(
new Brackets((qb) => new Brackets((qb) =>
@@ -181,14 +184,21 @@ export class MoneyAmountRepository extends Repository<MoneyAmount> {
if (customer_id) { if (customer_id) {
qb.leftJoin("price_list.customer_groups", "cgroup") qb.leftJoin("price_list.customer_groups", "cgroup")
.leftJoin("customer_group_customers", "cgc", "cgc.customer_group_id = cgroup.id") .leftJoin(
.andWhere("(cgc.customer_group_id is null OR cgc.customer_id = :customer_id)", { "customer_group_customers",
"cgc",
"cgc.customer_group_id = cgroup.id"
)
.andWhere(
"(cgc.customer_group_id is null OR cgc.customer_id = :customer_id)",
{
customer_id, customer_id,
}) }
)
} else { } else {
qb qb.leftJoin("price_list.customer_groups", "cgroup").andWhere(
.leftJoin("price_list.customer_groups", "cgroup") "cgroup.id is null"
.andWhere("cgroup.id is null") )
} }
return await qb.getManyAndCount() return await qb.getManyAndCount()
} }
@@ -28,6 +28,22 @@ export const carts = {
total: 1000, total: 1000,
region_id: IdMap.getId("testRegion"), region_id: IdMap.getId("testRegion"),
}, },
testCartTaxInclusive: {
id: IdMap.getId("test-cart"),
items: [],
payment: {
data: "some-data",
},
payment_session: {
status: "authorized",
},
total: 1000,
region_id: IdMap.getId("testRegion"),
shipping_options: [{
id: IdMap.getId("tax-inclusive-option"),
includes_tax: true
}],
},
testSwapCart: { testSwapCart: {
id: IdMap.getId("test-swap"), id: IdMap.getId("test-swap"),
items: [], items: [],
@@ -263,6 +279,9 @@ export const CartServiceMock = {
if (cartId === IdMap.getId("test-cart2")) { if (cartId === IdMap.getId("test-cart2")) {
return Promise.resolve(carts.testCart) return Promise.resolve(carts.testCart)
} }
if (cartId === IdMap.getId("tax-inclusive-option")) {
return Promise.resolve(carts.testCartTaxInclusive)
}
throw new MedusaError(MedusaError.Types.NOT_FOUND, "cart not found") throw new MedusaError(MedusaError.Types.NOT_FOUND, "cart not found")
}), }),
addLineItem: jest.fn().mockImplementation((cartId, lineItem) => { addLineItem: jest.fn().mockImplementation((cartId, lineItem) => {
@@ -0,0 +1,40 @@
import { IdMap } from "medusa-test-utils"
export const currency = {
code: IdMap.getId("currency-1"),
symbol: "SYM",
symbol_native: "SYM",
name: "Symbol",
}
export const CurrencyServiceMock = {
withTransaction: function() {
return this
},
retrieve: jest.fn().mockImplementation((code) => {
return Promise.resolve({
...currency,
code: code,
})
}),
update: jest.fn().mockImplementation((code, data) => {
return Promise.resolve({
...currency,
...data,
})
}),
listAndCount: jest.fn().mockImplementation(() => {
return Promise.resolve([
[currency],
1,
])
})
}
const mock = jest.fn().mockImplementation(() => {
return CurrencyServiceMock
})
export default mock
@@ -0,0 +1,60 @@
import { IdMap, MockManager, MockRepository } from "medusa-test-utils"
import { EventBusService } from "../index"
import { Currency } from "../../models"
import CurrencyService from "../currency"
import { FlagRouter } from "../../utils/flag-router"
import TaxInclusivePricingFeatureFlag from "../../loaders/feature-flags/tax-inclusive-pricing"
const currencyCode = IdMap.getId("currency-1")
const eventBusServiceMock = {
emit: jest.fn(),
withTransaction: function() {
return this
},
} as unknown as EventBusService
const currencyRepositoryMock = MockRepository({
findOne: jest.fn().mockImplementation(() => {
return {
code: currencyCode
}
}),
save: jest.fn().mockImplementation((data) => {
return Object.assign(new Currency(), data)
})
})
describe('CurrencyService', () => {
const currencyService = new CurrencyService({
manager: MockManager,
currencyRepository: currencyRepositoryMock,
eventBusService: eventBusServiceMock,
featureFlagRouter: new FlagRouter({
[TaxInclusivePricingFeatureFlag.key]: true
}),
})
afterEach(() => {
jest.clearAllMocks()
})
it("should retrieve the currency by calling the repository findOne method", async () => {
await currencyService.retrieveByCode(currencyCode)
expect(currencyRepositoryMock.findOne).toHaveBeenCalledWith({
where: { code: currencyCode.toLowerCase() },
})
})
it("should update the currency by calling the save method", async () => {
await currencyService.update(currencyCode, {
includes_tax: true,
})
expect(currencyRepositoryMock.findOne).toHaveBeenCalledWith({
where: { code: currencyCode.toLowerCase() },
})
expect(currencyRepositoryMock.save).toHaveBeenCalledWith({
code: currencyCode,
includes_tax: true,
})
})
})
@@ -1,5 +1,8 @@
import { IdMap, MockManager, MockRepository } from "medusa-test-utils" import { IdMap, MockManager, MockRepository } from "medusa-test-utils"
import DiscountService from "../discount" import DiscountService from "../discount"
import { FlagRouter } from "../../utils/flag-router"
const featureFlagRouter = new FlagRouter({})
describe("DiscountService", () => { describe("DiscountService", () => {
describe("create", () => { describe("create", () => {
@@ -23,6 +26,7 @@ describe("DiscountService", () => {
discountRepository, discountRepository,
discountRuleRepository, discountRuleRepository,
regionService, regionService,
featureFlagRouter,
}) })
beforeEach(() => { beforeEach(() => {
@@ -160,6 +164,7 @@ describe("DiscountService", () => {
const discountService = new DiscountService({ const discountService = new DiscountService({
manager: MockManager, manager: MockManager,
discountRepository, discountRepository,
featureFlagRouter,
}) })
beforeEach(() => { beforeEach(() => {
@@ -203,6 +208,7 @@ describe("DiscountService", () => {
const discountService = new DiscountService({ const discountService = new DiscountService({
manager: MockManager, manager: MockManager,
discountRepository, discountRepository,
featureFlagRouter,
}) })
beforeEach(() => { beforeEach(() => {
@@ -248,6 +254,7 @@ describe("DiscountService", () => {
discountRepository, discountRepository,
discountRuleRepository, discountRuleRepository,
regionService, regionService,
featureFlagRouter,
}) })
beforeEach(() => { beforeEach(() => {
@@ -345,6 +352,7 @@ describe("DiscountService", () => {
discountRepository, discountRepository,
discountRuleRepository, discountRuleRepository,
regionService, regionService,
featureFlagRouter,
}) })
beforeEach(() => { beforeEach(() => {
@@ -418,6 +426,7 @@ describe("DiscountService", () => {
discountRepository, discountRepository,
discountRuleRepository, discountRuleRepository,
regionService, regionService,
featureFlagRouter,
}) })
beforeEach(() => { beforeEach(() => {
@@ -466,6 +475,7 @@ describe("DiscountService", () => {
discountRepository, discountRepository,
discountRuleRepository, discountRuleRepository,
regionService, regionService,
featureFlagRouter,
}) })
beforeEach(() => { beforeEach(() => {
@@ -509,6 +519,7 @@ describe("DiscountService", () => {
const discountService = new DiscountService({ const discountService = new DiscountService({
manager: MockManager, manager: MockManager,
discountRepository, discountRepository,
featureFlagRouter,
}) })
beforeEach(() => { beforeEach(() => {
@@ -588,6 +599,7 @@ describe("DiscountService", () => {
manager: MockManager, manager: MockManager,
discountRepository, discountRepository,
totalsService, totalsService,
featureFlagRouter,
}) })
beforeEach(() => { beforeEach(() => {
@@ -763,6 +775,7 @@ describe("DiscountService", () => {
beforeEach(async () => { beforeEach(async () => {
discountService = new DiscountService({ discountService = new DiscountService({
manager: MockManager, manager: MockManager,
featureFlagRouter,
}) })
const hasReachedLimitMock = jest.fn().mockImplementation(() => false) const hasReachedLimitMock = jest.fn().mockImplementation(() => false)
const isDisabledMock = jest.fn().mockImplementation(() => false) const isDisabledMock = jest.fn().mockImplementation(() => false)
@@ -892,7 +905,9 @@ describe("DiscountService", () => {
}) })
describe("hasReachedLimit", () => { describe("hasReachedLimit", () => {
const discountService = new DiscountService({}) const discountService = new DiscountService({
featureFlagRouter,
})
it("returns true if discount limit is reached", () => { it("returns true if discount limit is reached", () => {
const discount = { const discount = {
@@ -936,7 +951,9 @@ describe("DiscountService", () => {
}) })
describe("isDisabled", () => { describe("isDisabled", () => {
const discountService = new DiscountService({}) const discountService = new DiscountService({
featureFlagRouter,
})
it("returns false if discount not disabled", async () => { it("returns false if discount not disabled", async () => {
const discount = { const discount = {
@@ -972,7 +989,9 @@ describe("DiscountService", () => {
}) })
describe("hasNotStarted", () => { describe("hasNotStarted", () => {
const discountService = new DiscountService({}) const discountService = new DiscountService({
featureFlagRouter,
})
it("returns true if discount has a future starts_at date", async () => { it("returns true if discount has a future starts_at date", async () => {
const discount = { const discount = {
@@ -1008,7 +1027,9 @@ describe("DiscountService", () => {
}) })
describe("hasExpired", () => { describe("hasExpired", () => {
const discountService = new DiscountService({}) const discountService = new DiscountService({
featureFlagRouter,
})
it("returns false if discount has a future ends_at date", async () => { it("returns false if discount has a future ends_at date", async () => {
const discount = { const discount = {
@@ -1068,6 +1089,7 @@ describe("DiscountService", () => {
const discountService = new DiscountService({ const discountService = new DiscountService({
manager: MockManager, manager: MockManager,
featureFlagRouter,
}) })
discountService.retrieve = retrieveMock discountService.retrieve = retrieveMock
@@ -1181,6 +1203,7 @@ describe("DiscountService", () => {
manager: MockManager, manager: MockManager,
discountConditionRepository, discountConditionRepository,
customerService, customerService,
featureFlagRouter,
}) })
it("returns false on undefined customer id", async () => { it("returns false on undefined customer id", async () => {
@@ -1,7 +1,9 @@
import { IdMap, MockManager, MockRepository } from "medusa-test-utils" import { IdMap, MockManager, MockRepository } from "medusa-test-utils"
import { FlagRouter } from "../../utils/flag-router"
import LineItemService from "../line-item" import LineItemService from "../line-item"
;[true, false].forEach((isTaxInclusiveEnabled) => {
describe("LineItemService", () => { describe(`tax inclusive flag set to: ${isTaxInclusiveEnabled}`, () => {
describe("LineItemService", () => {
describe("create", () => { describe("create", () => {
const lineItemRepository = MockRepository({ const lineItemRepository = MockRepository({
create: (data) => data, create: (data) => data,
@@ -70,6 +72,10 @@ describe("LineItemService", () => {
}, },
} }
const featureFlagRouter = new FlagRouter({
tax_inclusive_pricing: isTaxInclusiveEnabled,
})
const lineItemService = new LineItemService({ const lineItemService = new LineItemService({
manager: MockManager, manager: MockManager,
pricingService, pricingService,
@@ -77,6 +83,7 @@ describe("LineItemService", () => {
productVariantService, productVariantService,
regionService, regionService,
cartRepository, cartRepository,
featureFlagRouter,
}) })
beforeEach(async () => { beforeEach(async () => {
@@ -282,4 +289,233 @@ describe("LineItemService", () => {
}) })
}) })
}) })
})
})
})
describe("LineItemService", () => {
describe(`tax inclusive pricing tests `, () => {
describe("generate", () => {
const lineItemRepository = MockRepository({
create: (data) => data,
})
const cartRepository = MockRepository({
findOne: () =>
Promise.resolve({
region_id: IdMap.getId("test-region"),
}),
})
const regionService = {
withTransaction: function () {
return this
},
retrieve: () => {
return {
id: IdMap.getId("test-region"),
}
},
}
const productVariantService = {
withTransaction: function () {
return this
},
retrieve: (query) => {
if (query === IdMap.getId("test-giftcard")) {
return {
id: IdMap.getId("test-giftcard"),
title: "Test variant",
product: {
title: "Test product",
thumbnail: "",
is_giftcard: true,
discountable: false,
},
}
}
return {
id: IdMap.getId("test-variant"),
title: "Test variant",
product: {
title: "Test product",
thumbnail: "",
},
}
},
getRegionPrice: () => 100,
}
const pricingService = {
withTransaction: function () {
return this
},
getProductVariantPricingById: () => {
return {
calculated_price: 100,
calculated_price_includes_tax: true,
}
},
getProductVariantPricing: () => {
return {
calculated_price: 100,
calculated_price_includes_tax: true,
}
},
}
const featureFlagRouter = new FlagRouter({
tax_inclusive_pricing: true,
})
const lineItemService = new LineItemService({
manager: MockManager,
pricingService,
lineItemRepository,
productVariantService,
regionService,
cartRepository,
featureFlagRouter,
})
beforeEach(async () => {
jest.clearAllMocks()
})
it("successfully create a line item with tax inclusive set to true", async () => {
await lineItemService.generate(
IdMap.getId("test-variant"),
IdMap.getId("test-region"),
1
)
expect(lineItemRepository.create).toHaveBeenCalledTimes(1)
expect(lineItemRepository.create).toHaveBeenCalledWith({
unit_price: 100,
title: "Test product",
description: "Test variant",
thumbnail: "",
variant_id: IdMap.getId("test-variant"),
quantity: 1,
allow_discounts: undefined,
is_giftcard: undefined,
metadata: {},
should_merge: true,
includes_tax: true,
})
})
})
describe("generate", () => {
const lineItemRepository = MockRepository({
create: (data) => data,
})
const cartRepository = MockRepository({
findOne: () =>
Promise.resolve({
region_id: IdMap.getId("test-region"),
}),
})
const regionService = {
withTransaction: function () {
return this
},
retrieve: () => {
return {
id: IdMap.getId("test-region"),
}
},
}
const productVariantService = {
withTransaction: function () {
return this
},
retrieve: (query) => {
if (query === IdMap.getId("test-giftcard")) {
return {
id: IdMap.getId("test-giftcard"),
title: "Test variant",
product: {
title: "Test product",
thumbnail: "",
is_giftcard: true,
discountable: false,
},
}
}
return {
id: IdMap.getId("test-variant"),
title: "Test variant",
product: {
title: "Test product",
thumbnail: "",
},
}
},
getRegionPrice: () => 100,
}
const pricingService = {
withTransaction: function () {
return this
},
getProductVariantPricingById: () => {
return {
calculated_price: 100,
calculated_price_includes_tax: false,
}
},
getProductVariantPricing: () => {
return {
calculated_price: 100,
calculated_price_includes_tax: false,
}
},
}
const featureFlagRouter = new FlagRouter({
tax_inclusive_pricing: true,
})
const lineItemService = new LineItemService({
manager: MockManager,
pricingService,
lineItemRepository,
productVariantService,
regionService,
cartRepository,
featureFlagRouter,
})
beforeEach(async () => {
jest.clearAllMocks()
})
it("successfully create a line item with tax inclusive set to false", async () => {
await lineItemService.generate(
IdMap.getId("test-variant"),
IdMap.getId("test-region"),
1
)
expect(lineItemRepository.create).toHaveBeenCalledTimes(1)
expect(lineItemRepository.create).toHaveBeenCalledWith({
unit_price: 100,
title: "Test product",
description: "Test variant",
thumbnail: "",
variant_id: IdMap.getId("test-variant"),
quantity: 1,
allow_discounts: undefined,
is_giftcard: undefined,
metadata: {},
should_merge: true,
includes_tax: false,
})
})
})
})
}) })
@@ -1,8 +1,9 @@
import { MedusaError } from "medusa-core-utils" import { MedusaError } from "medusa-core-utils"
import { IdMap, MockManager, MockRepository } from "medusa-test-utils" import { IdMap, MockManager, MockRepository } from "medusa-test-utils"
import PriceListService from "../price-list"
import { MoneyAmountRepository } from "../../repositories/money-amount" import { MoneyAmountRepository } from "../../repositories/money-amount"
import { RegionServiceMock } from "../__mocks__/region"; import { FlagRouter } from "../../utils/flag-router"
import PriceListService from "../price-list"
import { RegionServiceMock } from "../__mocks__/region"
const priceListRepository = MockRepository({ const priceListRepository = MockRepository({
findOne: (q) => { findOne: (q) => {
@@ -42,6 +43,7 @@ describe("PriceListService", () => {
customerGroupService, customerGroupService,
priceListRepository, priceListRepository,
moneyAmountRepository, moneyAmountRepository,
featureFlagRouter: new FlagRouter({}),
}) })
beforeEach(async () => { beforeEach(async () => {
@@ -121,16 +123,21 @@ describe("PriceListService", () => {
describe("update", () => { describe("update", () => {
const updateRelatedMoneyAmountRepository = MockRepository() const updateRelatedMoneyAmountRepository = MockRepository()
updateRelatedMoneyAmountRepository.create = jest.fn().mockImplementation((rawEntity) => Promise.resolve(rawEntity)) updateRelatedMoneyAmountRepository.create = jest
updateRelatedMoneyAmountRepository.save = jest.fn().mockImplementation(() => Promise.resolve()) .fn()
updateRelatedMoneyAmountRepository.updatePriceListPrices = (new MoneyAmountRepository()).updatePriceListPrices .mockImplementation((rawEntity) => Promise.resolve(rawEntity))
updateRelatedMoneyAmountRepository.save = jest
.fn()
.mockImplementation(() => Promise.resolve())
updateRelatedMoneyAmountRepository.updatePriceListPrices = new MoneyAmountRepository().updatePriceListPrices
const updateRelatedPriceListService = new PriceListService({ const updateRelatedPriceListService = new PriceListService({
manager: MockManager, manager: MockManager,
customerGroupService, customerGroupService,
priceListRepository, priceListRepository,
moneyAmountRepository: updateRelatedMoneyAmountRepository, moneyAmountRepository: updateRelatedMoneyAmountRepository,
regionService: RegionServiceMock featureFlagRouter: new FlagRouter({}),
regionService: RegionServiceMock,
}) })
it("update only existing price lists and related money amount", async () => { it("update only existing price lists and related money amount", async () => {
@@ -1,12 +1,13 @@
import { IdMap, MockManager, MockRepository } from "medusa-test-utils" import { IdMap, MockManager, MockRepository } from "medusa-test-utils"
import RegionService from "../region" import { CreateRegionInput } from "../../types/region"
import { FlagRouter } from "../../utils/flag-router"
import { import {
EventBusService, EventBusService,
FulfillmentProviderService, FulfillmentProviderService,
PaymentProviderService, PaymentProviderService,
StoreService, StoreService,
} from "../index" } from "../index"
import { CreateRegionInput } from "../../types/region" import RegionService from "../region"
const eventBusService = { const eventBusService = {
emit: jest.fn(), emit: jest.fn(),
@@ -87,6 +88,7 @@ describe("RegionService", () => {
regionRepository, regionRepository,
countryRepository, countryRepository,
storeService, storeService,
featureFlagRouter: new FlagRouter({}),
fulfillmentProviderService, fulfillmentProviderService,
taxProviderRepository, taxProviderRepository,
paymentProviderService, paymentProviderService,
@@ -196,6 +198,7 @@ describe("RegionService", () => {
manager: MockManager, manager: MockManager,
eventBusService, eventBusService,
regionRepository, regionRepository,
featureFlagRouter: new FlagRouter({}),
fulfillmentProviderService, fulfillmentProviderService,
taxProviderRepository, taxProviderRepository,
paymentProviderService, paymentProviderService,
@@ -250,6 +253,7 @@ describe("RegionService", () => {
currencyRepository, currencyRepository,
countryRepository, countryRepository,
storeService, storeService,
featureFlagRouter: new FlagRouter({}),
}) })
beforeEach(async () => { beforeEach(async () => {
@@ -312,6 +316,7 @@ describe("RegionService", () => {
currencyRepository, currencyRepository,
countryRepository, countryRepository,
storeService, storeService,
featureFlagRouter: new FlagRouter({}),
}) })
beforeEach(async () => { beforeEach(async () => {
@@ -367,6 +372,7 @@ describe("RegionService", () => {
paymentProviderRepository: ppRepository, paymentProviderRepository: ppRepository,
currencyRepository, currencyRepository,
countryRepository, countryRepository,
featureFlagRouter: new FlagRouter({}),
storeService, storeService,
}) })
@@ -427,6 +433,7 @@ describe("RegionService", () => {
paymentProviderRepository: ppRepository, paymentProviderRepository: ppRepository,
currencyRepository, currencyRepository,
countryRepository, countryRepository,
featureFlagRouter: new FlagRouter({}),
storeService, storeService,
}) })
@@ -472,6 +479,7 @@ describe("RegionService", () => {
manager: MockManager, manager: MockManager,
eventBusService, eventBusService,
regionRepository, regionRepository,
featureFlagRouter: new FlagRouter({}),
} as any) } as any)
beforeEach(async () => { beforeEach(async () => {
@@ -507,6 +515,7 @@ describe("RegionService", () => {
paymentProviderService, paymentProviderService,
fulfillmentProviderRepository: fpRepository, fulfillmentProviderRepository: fpRepository,
paymentProviderRepository: ppRepository, paymentProviderRepository: ppRepository,
featureFlagRouter: new FlagRouter({}),
currencyRepository, currencyRepository,
countryRepository, countryRepository,
storeService, storeService,
@@ -569,6 +578,7 @@ describe("RegionService", () => {
manager: MockManager, manager: MockManager,
eventBusService, eventBusService,
regionRepository, regionRepository,
featureFlagRouter: new FlagRouter({}),
fulfillmentProviderService, fulfillmentProviderService,
taxProviderRepository, taxProviderRepository,
paymentProviderService, paymentProviderService,
@@ -626,6 +636,7 @@ describe("RegionService", () => {
manager: MockManager, manager: MockManager,
eventBusService, eventBusService,
regionRepository, regionRepository,
featureFlagRouter: new FlagRouter({}),
} as any) } as any)
beforeEach(async () => { beforeEach(async () => {
@@ -661,6 +672,7 @@ describe("RegionService", () => {
manager: MockManager, manager: MockManager,
eventBusService, eventBusService,
regionRepository, regionRepository,
featureFlagRouter: new FlagRouter({}),
} as any) } as any)
beforeEach(async () => { beforeEach(async () => {
@@ -1,6 +1,8 @@
import _ from "lodash" import _ from "lodash"
import { IdMap, MockRepository, MockManager } from "medusa-test-utils" import { IdMap, MockRepository, MockManager } from "medusa-test-utils"
import ShippingOptionService from "../shipping-option" import ShippingOptionService from "../shipping-option"
import { FlagRouter } from "../../utils/flag-router";
import TaxInclusivePricingFeatureFlag from "../../loaders/feature-flags/tax-inclusive-pricing";
describe("ShippingOptionService", () => { describe("ShippingOptionService", () => {
describe("retrieve", () => { describe("retrieve", () => {
@@ -13,6 +15,7 @@ describe("ShippingOptionService", () => {
const optionService = new ShippingOptionService({ const optionService = new ShippingOptionService({
manager: MockManager, manager: MockManager,
shippingOptionRepository, shippingOptionRepository,
featureFlagRouter: new FlagRouter({}),
}) })
it("successfully gets shipping option", async () => { it("successfully gets shipping option", async () => {
@@ -60,6 +63,7 @@ describe("ShippingOptionService", () => {
shippingOptionRepository, shippingOptionRepository,
shippingOptionRequirementRepository, shippingOptionRequirementRepository,
fulfillmentProviderService, fulfillmentProviderService,
featureFlagRouter: new FlagRouter({}),
}) })
beforeEach(() => { beforeEach(() => {
@@ -214,6 +218,7 @@ describe("ShippingOptionService", () => {
const optionService = new ShippingOptionService({ const optionService = new ShippingOptionService({
manager: MockManager, manager: MockManager,
shippingOptionRepository, shippingOptionRepository,
featureFlagRouter: new FlagRouter({}),
}) })
beforeEach(() => { beforeEach(() => {
@@ -262,6 +267,7 @@ describe("ShippingOptionService", () => {
manager: MockManager, manager: MockManager,
shippingOptionRepository, shippingOptionRepository,
shippingOptionRequirementRepository, shippingOptionRequirementRepository,
featureFlagRouter: new FlagRouter({}),
}) })
beforeEach(() => { beforeEach(() => {
@@ -311,6 +317,7 @@ describe("ShippingOptionService", () => {
const optionService = new ShippingOptionService({ const optionService = new ShippingOptionService({
manager: MockManager, manager: MockManager,
shippingOptionRequirementRepository, shippingOptionRequirementRepository,
featureFlagRouter: new FlagRouter({}),
}) })
beforeEach(() => { beforeEach(() => {
@@ -363,6 +370,7 @@ describe("ShippingOptionService", () => {
shippingOptionRequirementRepository, shippingOptionRequirementRepository,
fulfillmentProviderService, fulfillmentProviderService,
regionService, regionService,
featureFlagRouter: new FlagRouter({}),
}) })
beforeEach(() => { beforeEach(() => {
@@ -526,6 +534,7 @@ describe("ShippingOptionService", () => {
shippingOptionRepository, shippingOptionRepository,
totalsService, totalsService,
fulfillmentProviderService: providerService, fulfillmentProviderService: providerService,
featureFlagRouter: new FlagRouter({}),
}) })
beforeEach(() => { beforeEach(() => {
@@ -589,4 +598,68 @@ describe("ShippingOptionService", () => {
) )
}) })
}) })
describe("[MEDUSA_FF_TAX_INCLUSIVE_PRICING] createShippingMethod", () => {
const option = (id) => ({
id,
region_id: IdMap.getId("region"),
price_type: "flat_rate",
amount: 10,
includes_tax: true,
data: {
something: "yes",
},
requirements: [
{
type: "min_subtotal",
amount: 100,
},
],
})
const shippingOptionRepository = MockRepository({
findOne: (q) => {
switch (q.where.id) {
default:
return Promise.resolve(option(q.where.id))
}
},
})
const shippingMethodRepository = MockRepository({ create: (r) => r })
const totalsService = {
getSubtotal: (c) => {
return c.subtotal
},
}
const providerService = {
validateFulfillmentData: jest
.fn()
.mockImplementation((r) => Promise.resolve(r.data)),
getPrice: (d) => d.price,
}
const optionService = new ShippingOptionService({
manager: MockManager,
shippingMethodRepository,
shippingOptionRepository,
totalsService,
fulfillmentProviderService: providerService,
featureFlagRouter: new FlagRouter({
[TaxInclusivePricingFeatureFlag.key]: true
}),
})
beforeEach(() => {
jest.clearAllMocks()
})
it("should create a shipping method that also includes the taxes", async () => {
await optionService.createShippingMethod("random_id", {}, { price: 10 })
expect(shippingMethodRepository.save).toHaveBeenCalledWith(
expect.objectContaining({
includes_tax: true
})
)
})
})
}) })
+391 -30
View File
@@ -1,5 +1,9 @@
import { IdMap } from "medusa-test-utils" import { IdMap } from "medusa-test-utils"
import TotalsService from "../totals" import TotalsService from "../totals"
import { FlagRouter } from "../../utils/flag-router"
import TaxInclusivePricingFeatureFlag from "../../loaders/feature-flags/tax-inclusive-pricing"
import { calculatePriceTaxAmount } from "../../utils"
const discounts = { const discounts = {
total10Percent: { total10Percent: {
@@ -83,11 +87,21 @@ const applyDiscount = (cart, discount) => {
const calculateAdjustment = (cart, lineItem, discount) => { const calculateAdjustment = (cart, lineItem, discount) => {
let amount = discount.rule.value * lineItem.quantity let amount = discount.rule.value * lineItem.quantity
let lineItemPrice = lineItem.unit_price * lineItem.quantity const taxAmountIncludedInPrice = !lineItem.includes_tax
? 0
: Math.round(
calculatePriceTaxAmount({
price: lineItem.unit_price,
taxRate: cart.tax_rate / 100,
includesTax: lineItem.includes_tax,
})
)
let price = lineItem.unit_price - taxAmountIncludedInPrice
const lineItemPrice = price * lineItem.quantity
if (discount.rule.type === "fixed" && discount.rule.allocation === "total") { if (discount.rule.type === "fixed" && discount.rule.allocation === "total") {
let subtotal = cart.items.reduce( let subtotal = cart.items.reduce(
(total, item) => total + item.unit_price * item.quantity, (total, item) => total + price * item.quantity,
0 0
) )
const nominator = Math.min(discount.rule.value, subtotal) const nominator = Math.min(discount.rule.value, subtotal)
@@ -99,13 +113,20 @@ const calculateAdjustment = (cart, lineItem, discount) => {
} }
describe("TotalsService", () => { describe("TotalsService", () => {
const getTaxLinesMock = jest.fn(() => Promise.resolve([{ id: "line1" }]))
const featureFlagRouter = new FlagRouter({
[TaxInclusivePricingFeatureFlag.key]: false,
})
const container = { const container = {
taxProviderService: { taxProviderService: {
withTransaction: function () { withTransaction: function() {
return this return this
}, },
getTaxLines: getTaxLinesMock,
}, },
taxCalculationStrategy: {}, taxCalculationStrategy: {},
featureFlagRouter,
} }
describe("getAllocationItemDiscounts", () => { describe("getAllocationItemDiscounts", () => {
@@ -278,7 +299,7 @@ describe("TotalsService", () => {
it("calculate total percentage discount", async () => { it("calculate total percentage discount", async () => {
discountCart.discounts.push(discounts.total10Percent) discountCart.discounts.push(discounts.total10Percent)
let cart = applyDiscount(discountCart, discounts.total10Percent) let cart = applyDiscount(discountCart, discounts.total10Percent)
res = totalsService.getDiscountTotal(cart) res = await totalsService.getDiscountTotal(cart)
expect(res).toEqual(28) expect(res).toEqual(28)
}) })
@@ -288,7 +309,7 @@ describe("TotalsService", () => {
it("calculate item fixed discount", async () => { it("calculate item fixed discount", async () => {
discountCart.discounts.push(discounts.item2Fixed) discountCart.discounts.push(discounts.item2Fixed)
let cart = applyDiscount(discountCart, discounts.item2Fixed) let cart = applyDiscount(discountCart, discounts.item2Fixed)
res = totalsService.getDiscountTotal(cart) res = await totalsService.getDiscountTotal(cart)
expect(res).toEqual(40) expect(res).toEqual(40)
}) })
@@ -296,7 +317,7 @@ describe("TotalsService", () => {
it("calculate item percentage discount", async () => { it("calculate item percentage discount", async () => {
discountCart.discounts.push(discounts.item10Percent) discountCart.discounts.push(discounts.item10Percent)
let cart = applyDiscount(discountCart, discounts.item10Percent) let cart = applyDiscount(discountCart, discounts.item10Percent)
res = totalsService.getDiscountTotal(cart) res = await totalsService.getDiscountTotal(cart)
expect(res).toEqual(28) expect(res).toEqual(28)
}) })
@@ -304,26 +325,26 @@ describe("TotalsService", () => {
it("calculate total fixed discount", async () => { it("calculate total fixed discount", async () => {
discountCart.discounts.push(discounts.total10Fixed) discountCart.discounts.push(discounts.total10Fixed)
let cart = applyDiscount(discountCart, discounts.total10Fixed) let cart = applyDiscount(discountCart, discounts.total10Fixed)
res = totalsService.getDiscountTotal(cart) res = await totalsService.getDiscountTotal(cart)
expect(res).toEqual(10) expect(res).toEqual(10)
}) })
it("ignores discount if expired", async () => { it("ignores discount if expired", async () => {
discountCart.discounts.push(discounts.expiredDiscount) discountCart.discounts.push(discounts.expiredDiscount)
res = totalsService.getDiscountTotal(discountCart) res = await totalsService.getDiscountTotal(discountCart)
expect(res).toEqual(0) expect(res).toEqual(0)
}) })
it("returns 0 if no discounts are applied", async () => { it("returns 0 if no discounts are applied", async () => {
res = totalsService.getDiscountTotal(discountCart) res = await totalsService.getDiscountTotal(discountCart)
expect(res).toEqual(0) expect(res).toEqual(0)
}) })
it("returns 0 if no items are in cart", async () => { it("returns 0 if no items are in cart", async () => {
res = totalsService.getDiscountTotal({ res = await totalsService.getDiscountTotal({
items: [], items: [],
discounts: [discounts.total10Fixed], discounts: [discounts.total10Fixed],
}) })
@@ -385,7 +406,7 @@ describe("TotalsService", () => {
}) })
it("calculates refund", async () => { it("calculates refund", async () => {
res = totalsService.getRefundTotal(orderToRefund, [ res = await totalsService.getRefundTotal(orderToRefund, [
{ {
id: "line2", id: "line2",
unit_price: 100, unit_price: 100,
@@ -447,7 +468,7 @@ describe("TotalsService", () => {
it("calculates refund with item fixed discount", async () => { it("calculates refund with item fixed discount", async () => {
orderToRefund.discounts.push(discounts.item2Fixed) orderToRefund.discounts.push(discounts.item2Fixed)
let order = applyDiscount(orderToRefund, discounts.item2Fixed) let order = applyDiscount(orderToRefund, discounts.item2Fixed)
res = totalsService.getRefundTotal(order, [ res = await totalsService.getRefundTotal(order, [
{ {
id: "line2", id: "line2",
unit_price: 100, unit_price: 100,
@@ -467,7 +488,7 @@ describe("TotalsService", () => {
it("calculates refund with item percentage discount", async () => { it("calculates refund with item percentage discount", async () => {
orderToRefund.discounts.push(discounts.item10Percent) orderToRefund.discounts.push(discounts.item10Percent)
let order = applyDiscount(orderToRefund, discounts.item10Percent) let order = applyDiscount(orderToRefund, discounts.item10Percent)
res = totalsService.getRefundTotal(order, [ res = await totalsService.getRefundTotal(order, [
{ {
id: "line2", id: "line2",
unit_price: 100, unit_price: 100,
@@ -485,8 +506,9 @@ describe("TotalsService", () => {
}) })
it("throws if line items to return is not in order", async () => { it("throws if line items to return is not in order", async () => {
const work = () => let errMsg
totalsService.getRefundTotal(orderToRefund, [ await totalsService
.getRefundTotal(orderToRefund, [
{ {
id: "notInOrder", id: "notInOrder",
unit_price: 123, unit_price: 123,
@@ -498,14 +520,213 @@ describe("TotalsService", () => {
quantity: 1, quantity: 1,
}, },
]) ])
.catch((e) => (errMsg = e.message))
expect(work).toThrow("Line item does not exist on order") expect(errMsg).toBe("Line item does not exist on order")
})
})
describe("[MEDUSA_FF_TAX_INCLUSIVE_PRICING] getRefundTotal", () => {
let res
const totalsService = new TotalsService({
...container,
featureFlagRouter: new FlagRouter({
[TaxInclusivePricingFeatureFlag.key]: true,
}),
})
const orderToRefund = {
id: "refund-order",
tax_rate: 25,
items: [
{
id: "line",
unit_price: 125,
includes_tax: true,
allow_discounts: true,
variant: {
id: "variant",
product_id: "testp1",
},
quantity: 10,
returned_quantity: 0,
},
{
id: "line2",
unit_price: 100,
allow_discounts: true,
variant: {
id: "variant",
product_id: "testp2",
},
quantity: 10,
returned_quantity: 0,
metadata: {},
},
{
id: "non-discount",
unit_price: 100,
allow_discounts: false,
variant: {
id: "variant",
product_id: "testp2",
},
quantity: 1,
returned_quantity: 0,
metadata: {},
},
],
region_id: "fr",
discounts: [],
}
beforeEach(() => {
jest.clearAllMocks()
orderToRefund.discounts = []
})
it("calculates refund", async () => {
res = await totalsService.getRefundTotal(orderToRefund, [
{
id: "line2",
unit_price: 100,
allow_discounts: true,
variant: {
id: "variant",
product_id: "product2",
},
quantity: 10,
returned_quantity: 0,
metadata: {},
},
])
expect(res).toEqual(1250)
})
it("calculates refund with line that includes tax", async () => {
res = await totalsService.getRefundTotal(orderToRefund, [
{
id: "line",
unit_price: 125,
includes_tax: true,
allow_discounts: true,
variant: {
id: "variant",
product_id: "product2",
},
quantity: 10,
returned_quantity: 0,
metadata: {},
},
])
expect(res).toEqual(1250)
})
it("calculates refund with item fixed discount", async () => {
orderToRefund.discounts.push(discounts.item2Fixed)
let order = applyDiscount(orderToRefund, discounts.item2Fixed)
res = await 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)
})
it("calculates refund with item fixed discount and a line that includes tax", async () => {
orderToRefund.discounts.push(discounts.item2Fixed)
let order = applyDiscount(orderToRefund, discounts.item2Fixed)
res = await totalsService.getRefundTotal(order, [
{
id: "line",
unit_price: 125,
includes_tax: true,
allow_discounts: true,
variant: {
id: "variant",
product_id: "testp2",
},
quantity: 10,
returned_quantity: 0,
},
])
expect(res).toEqual(1225)
})
it("calculates refund with item percentage discount", async () => {
orderToRefund.discounts.push(discounts.item10Percent)
let order = applyDiscount(orderToRefund, discounts.item10Percent)
res = await 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)
})
it("calculates refund with item percentage discount and a line that includes tax", async () => {
orderToRefund.discounts.push(discounts.item10Percent)
let order = applyDiscount(orderToRefund, discounts.item10Percent)
res = await totalsService.getRefundTotal(order, [
{
id: "line",
unit_price: 125,
includes_tax: true,
allow_discounts: true,
variant: {
id: "variant",
product_id: "testp2",
},
quantity: 10,
returned_quantity: 0,
},
])
expect(res).toEqual(1125)
}) })
}) })
describe("getShippingTotal", () => { describe("getShippingTotal", () => {
let res const getTaxLinesMock = jest.fn(() =>
const totalsService = new TotalsService(container) Promise.resolve([
{ shipping_method_id: IdMap.getId("expensiveShipping") },
])
)
const calculateMock = jest.fn(() => Promise.resolve(20))
const totalsService = new TotalsService({
...container,
taxProviderService: {
withTransaction: function() {
return this
},
getTaxLines: getTaxLinesMock,
},
taxCalculationStrategy: {
calculate: calculateMock,
},
})
beforeEach(() => { beforeEach(() => {
jest.clearAllMocks() jest.clearAllMocks()
@@ -515,7 +736,7 @@ describe("TotalsService", () => {
const order = { const order = {
shipping_methods: [ shipping_methods: [
{ {
_id: IdMap.getId("expensiveShipping"), id: IdMap.getId("expensiveShipping"),
name: "Expensive Shipping", name: "Expensive Shipping",
price: 100, price: 100,
provider_id: "default_provider", provider_id: "default_provider",
@@ -526,11 +747,12 @@ describe("TotalsService", () => {
}, },
], ],
} }
res = totalsService.getShippingTotal(order) const total = await totalsService.getShippingTotal(order)
expect(res).toEqual(100) expect(total).toEqual(100)
}) })
}) })
describe("getTaxTotal", () => { describe("getTaxTotal", () => {
let res let res
let totalsService let totalsService
@@ -541,7 +763,7 @@ describe("TotalsService", () => {
const cradle = { const cradle = {
taxProviderService: { taxProviderService: {
withTransaction: function () { withTransaction: function() {
return this return this
}, },
getTaxLines: getTaxLinesMock, getTaxLines: getTaxLinesMock,
@@ -549,6 +771,7 @@ describe("TotalsService", () => {
taxCalculationStrategy: { taxCalculationStrategy: {
calculate: calculateMock, calculate: calculateMock,
}, },
featureFlagRouter,
} }
beforeEach(() => { beforeEach(() => {
@@ -597,13 +820,14 @@ describe("TotalsService", () => {
expect(res).toEqual(20) expect(res).toEqual(20)
expect(getAllocationMapMock).toHaveBeenCalledTimes(1) expect(getAllocationMapMock).toHaveBeenCalledTimes(2)
expect(getAllocationMapMock).toHaveBeenCalledWith(order, {}) expect(getAllocationMapMock).toHaveBeenNthCalledWith(1, order, {})
expect(getTaxLinesMock).toHaveBeenCalledTimes(0) expect(getTaxLinesMock).toHaveBeenCalledTimes(0)
expect(calculateMock).toHaveBeenCalledTimes(1) expect(calculateMock).toHaveBeenCalledTimes(3)
expect(calculateMock).toHaveBeenCalledWith( expect(calculateMock).toHaveBeenNthCalledWith(
3,
order.items, order.items,
[{ id: "orderline1" }], [{ id: "orderline1" }],
{ {
@@ -652,11 +876,15 @@ describe("TotalsService", () => {
expect(res).toEqual(20) expect(res).toEqual(20)
expect(getAllocationMapMock).toHaveBeenCalledTimes(1) expect(getAllocationMapMock).toHaveBeenCalledTimes(2)
expect(getAllocationMapMock).toHaveBeenCalledWith(order, {}) expect(getAllocationMapMock).toHaveBeenNthCalledWith(2, order, {
exclude_discounts: undefined,
exclude_gift_cards: true,
})
expect(getTaxLinesMock).toHaveBeenCalledTimes(1) expect(getTaxLinesMock).toHaveBeenCalledTimes(2)
expect(getTaxLinesMock).toHaveBeenCalledWith( expect(getTaxLinesMock).toHaveBeenNthCalledWith(
2,
[{ quantity: 2, unit_price: 20 }], [{ quantity: 2, unit_price: 20 }],
{ {
shipping_address: order.shipping_address, shipping_address: order.shipping_address,
@@ -726,4 +954,137 @@ describe("TotalsService", () => {
expect(res).toEqual(175) expect(res).toEqual(175)
}) })
}) })
describe("[MEDUSA_FF_TAX_INCLUSIVE_PRICING] getTotal", () => {
let res
const totalsService = new TotalsService({
...container,
featureFlagRouter: new FlagRouter({
[TaxInclusivePricingFeatureFlag.key]: true,
}),
})
beforeEach(() => {
jest.clearAllMocks()
})
it("calculates total", async () => {
const order = {
region: {
tax_rate: 25,
},
items: [
{
unit_price: 20,
quantity: 2,
},
{
unit_price: 25,
quantity: 2,
includes_tax: true,
},
],
shipping_methods: [
{
_id: IdMap.getId("expensiveShipping"),
name: "Expensive Shipping",
price: 100,
provider_id: "default_provider",
profile_id: IdMap.getId("default"),
data: {
extra: "hi",
},
},
],
}
const getTaxTotalMock = jest.fn(() => Promise.resolve(45))
totalsService.getTaxTotal = getTaxTotalMock
res = await totalsService.getTotal(order)
expect(getTaxTotalMock).toHaveBeenCalledTimes(1)
expect(getTaxTotalMock).toHaveBeenCalledWith(order, undefined)
expect(res).toEqual(185)
})
})
describe("[MEDUSA_FF_TAX_INCLUSIVE_PRICING] getShippingTotal ", () => {
const shippingMethodData = {
id: IdMap.getId("expensiveShipping"),
name: "Expensive Shipping",
price: 120,
tax_lines: [{ shipping_method_id: IdMap.getId("expensiveShipping") }],
provider_id: "default_provider",
profile_id: IdMap.getId("default"),
data: {
extra: "hi",
},
}
const calculateMock = jest.fn(() => Promise.resolve(20))
const totalsService = new TotalsService({
...container,
taxCalculationStrategy: {
calculate: calculateMock,
},
featureFlagRouter: new FlagRouter({
[TaxInclusivePricingFeatureFlag.key]: true,
}),
})
beforeEach(() => {
jest.clearAllMocks()
})
it("calculates total with tax lines and being tax inclusive", async () => {
const order = {
object: "order",
shipping_methods: [
{
...shippingMethodData,
includes_tax: true,
},
],
}
const total = await totalsService.getShippingTotal(order)
expect(total).toEqual(100)
})
it("calculates total with tax lines and not being tax inclusive", async () => {
const order = {
object: "order",
shipping_methods: [
{
...shippingMethodData,
price: 100,
includes_tax: false,
},
],
}
const total = await totalsService.getShippingTotal(order)
expect(total).toEqual(100)
})
it("calculates total with the old system and not being tax inclusive", async () => {
const order = {
object: "order",
tax_rate: 20,
shipping_methods: [
{
...shippingMethodData,
price: 100,
includes_tax: false,
tax_lines: [],
},
],
}
const total = await totalsService.getShippingTotal(order)
expect(total).toEqual(100)
})
})
}) })
+24 -14
View File
@@ -243,11 +243,15 @@ class CartService extends TransactionBaseService {
break break
} }
case "shipping_total": { case "shipping_total": {
totals.shipping_total = this.totalsService_.getShippingTotal(cart) totals.shipping_total = await this.totalsService_.getShippingTotal(
cart
)
break break
} }
case "discount_total": case "discount_total":
totals.discount_total = this.totalsService_.getDiscountTotal(cart) totals.discount_total = await this.totalsService_.getDiscountTotal(
cart
)
break break
case "tax_total": case "tax_total":
totals.tax_total = await this.totalsService_.getTaxTotal( totals.tax_total = await this.totalsService_.getTaxTotal(
@@ -256,13 +260,15 @@ class CartService extends TransactionBaseService {
) )
break break
case "gift_card_total": { case "gift_card_total": {
const giftCardBreakdown = this.totalsService_.getGiftCardTotal(cart) const giftCardBreakdown = await this.totalsService_.getGiftCardTotal(
cart
)
totals.gift_card_total = giftCardBreakdown.total totals.gift_card_total = giftCardBreakdown.total
totals.gift_card_tax_total = giftCardBreakdown.tax_total totals.gift_card_tax_total = giftCardBreakdown.tax_total
break break
} }
case "subtotal": case "subtotal":
totals.subtotal = this.totalsService_.getSubtotal(cart) totals.subtotal = await this.totalsService_.getSubtotal(cart)
break break
default: default:
break break
@@ -518,7 +524,7 @@ class CartService extends TransactionBaseService {
.delete(lineItem.id) .delete(lineItem.id)
const result = await this.retrieve(cartId, { const result = await this.retrieve(cartId, {
relations: ["items", "discounts", "discounts.rule"], relations: ["items", "discounts", "discounts.rule", "region"],
}) })
await this.refreshAdjustments_(result) await this.refreshAdjustments_(result)
@@ -686,7 +692,7 @@ class CartService extends TransactionBaseService {
) )
const result = await this.retrieve(cartId, { const result = await this.retrieve(cartId, {
relations: ["items", "discounts", "discounts.rule"], relations: ["items", "discounts", "discounts.rule", "region"],
}) })
await this.refreshAdjustments_(result) await this.refreshAdjustments_(result)
@@ -748,7 +754,7 @@ class CartService extends TransactionBaseService {
.update(lineItemId, lineItemUpdate) .update(lineItemId, lineItemUpdate)
const updatedCart = await this.retrieve(cartId, { const updatedCart = await this.retrieve(cartId, {
relations: ["items", "discounts", "discounts.rule"], relations: ["items", "discounts", "discounts.rule", "region"],
}) })
await this.refreshAdjustments_(updatedCart) await this.refreshAdjustments_(updatedCart)
@@ -919,14 +925,14 @@ class CartService extends TransactionBaseService {
) )
const hasFreeShipping = cart.discounts.some( const hasFreeShipping = cart.discounts.some(
({ rule }) => rule?.type === "free_shipping" ({ rule }) => rule?.type === DiscountRuleType.FREE_SHIPPING
) )
// if we previously had a free shipping discount and then removed it, // if we previously had a free shipping discount and then removed it,
// we need to update shipping methods to original price // we need to update shipping methods to original price
if ( if (
previousDiscounts.some( previousDiscounts.some(
({ rule }) => rule.type === "free_shipping" ({ rule }) => rule.type === DiscountRuleType.FREE_SHIPPING
) && ) &&
!hasFreeShipping !hasFreeShipping
) { ) {
@@ -1229,7 +1235,7 @@ class CartService extends TransactionBaseService {
default: default:
if (!sawNotShipping) { if (!sawNotShipping) {
sawNotShipping = true sawNotShipping = true
if (rule?.type !== "free_shipping") { if (rule?.type !== DiscountRuleType.FREE_SHIPPING) {
return discount return discount
} }
return discountToParse return discountToParse
@@ -1245,7 +1251,7 @@ class CartService extends TransactionBaseService {
) )
// ignore if free shipping // ignore if free shipping
if (rule?.type !== "free_shipping" && cart?.items) { if (rule?.type !== DiscountRuleType.FREE_SHIPPING && cart?.items) {
await this.refreshAdjustments_(cart) await this.refreshAdjustments_(cart)
} }
} }
@@ -1270,7 +1276,11 @@ class CartService extends TransactionBaseService {
], ],
}) })
if (cart.discounts.some(({ rule }) => rule.type === "free_shipping")) { if (
cart.discounts.some(
({ rule }) => rule.type === DiscountRuleType.FREE_SHIPPING
)
) {
await this.adjustFreeShipping_(cart, false) await this.adjustFreeShipping_(cart, false)
} }
@@ -1770,7 +1780,7 @@ class CartService extends TransactionBaseService {
// if cart has freeshipping, adjust price // if cart has freeshipping, adjust price
if ( if (
updatedCart.discounts.some( updatedCart.discounts.some(
({ rule }) => rule.type === "free_shipping" ({ rule }) => rule.type === DiscountRuleType.FREE_SHIPPING
) )
) { ) {
await this.adjustFreeShipping_(updatedCart, true) await this.adjustFreeShipping_(updatedCart, true)
@@ -2107,7 +2117,7 @@ class CartService extends TransactionBaseService {
], ],
}) })
const calculationContext = this.totalsService_ const calculationContext = await this.totalsService_
.withTransaction(transactionManager) .withTransaction(transactionManager)
.getCalculationContext(cart) .getCalculationContext(cart)
+3 -1
View File
@@ -381,7 +381,9 @@ export default class ClaimService extends TransactionBaseService {
const result: ClaimOrder = await claimRepo.save(created) const result: ClaimOrder = await claimRepo.save(created)
if (result.additional_items && result.additional_items.length) { if (result.additional_items && result.additional_items.length) {
const calcContext = this.totalsService_.getCalculationContext(order) const calcContext = await this.totalsService_.getCalculationContext(
order
)
const lineItems = await lineItemServiceTx.list({ const lineItems = await lineItemServiceTx.list({
id: result.additional_items.map((i) => i.id), id: result.additional_items.map((i) => i.id),
}) })
+132
View File
@@ -0,0 +1,132 @@
import { MedusaError } from "medusa-core-utils"
import { EntityManager } from "typeorm"
import { TransactionBaseService } from "../interfaces"
import TaxInclusivePricingFeatureFlag from "../loaders/feature-flags/tax-inclusive-pricing"
import { Currency } from "../models"
import { CurrencyRepository } from "../repositories/currency"
import { FindConfig, Selector } from "../types/common"
import { UpdateCurrencyInput } from "../types/currency"
import { buildQuery } from "../utils"
import { FlagRouter } from "../utils/flag-router"
import EventBusService from "./event-bus"
type InjectedDependencies = {
manager: EntityManager
currencyRepository: typeof CurrencyRepository
eventBusService: EventBusService
featureFlagRouter: FlagRouter
}
export default class CurrencyService extends TransactionBaseService {
static readonly Events = {
UPDATED: "currency.updated",
}
protected manager_: EntityManager
protected transactionManager_: EntityManager | undefined
protected readonly currencyRepository_: typeof CurrencyRepository
protected readonly eventBusService_: EventBusService
protected readonly featureFlagRouter_: FlagRouter
constructor({
manager,
currencyRepository,
eventBusService,
featureFlagRouter,
}: InjectedDependencies) {
super({ manager })
this.manager_ = manager
this.currencyRepository_ = currencyRepository
this.eventBusService_ = eventBusService
this.featureFlagRouter_ = featureFlagRouter
}
/**
* Return the currency
* @param code - The code of the currency that must be retrieve
* @return The currency
*/
async retrieveByCode(code: string): Promise<Currency | never> {
const currencyRepo = this.manager_.getCustomRepository(
this.currencyRepository_
)
code = code.toLowerCase()
const currency = await currencyRepo.findOne({
where: { code },
})
if (!currency) {
throw new MedusaError(
MedusaError.Types.NOT_FOUND,
`Currency with code: ${code} was not found`
)
}
return currency
}
/**
* Lists currencies based on the provided parameters and includes the count of
* currencies that match the query.
* @param selector - an object that defines rules to filter currencies
* by
* @param config - object that defines the scope for what should be
* returned
* @return an array containing the currencies as
* the first element and the total count of products that matches the query
* as the second element.
*/
async listAndCount(
selector: Selector<Currency>,
config: FindConfig<Currency> = {
skip: 0,
take: 20,
}
): Promise<[Currency[], number]> {
const productRepo = this.manager_.getCustomRepository(
this.currencyRepository_
)
const query = buildQuery(selector, config)
return await productRepo.findAndCount(query)
}
/**
* Update a currency
* @param code - The code of the currency to update
* @param data - The data that must be updated on the currency
* @return The updated currency
*/
async update(
code: string,
data: UpdateCurrencyInput
): Promise<Currency | undefined | never> {
return await this.atomicPhase_(async (transactionManager) => {
const currency = await this.retrieveByCode(code)
if (
this.featureFlagRouter_.isFeatureEnabled(
TaxInclusivePricingFeatureFlag.key
)
) {
if (typeof data.includes_tax !== "undefined") {
currency.includes_tax = data.includes_tax
}
}
const currencyRepo = transactionManager.getCustomRepository(
this.currencyRepository_
)
await currencyRepo.save(currency)
await this.eventBusService_.emit(CurrencyService.Events.UPDATED, {
code,
})
return currency
})
}
}
+24 -4
View File
@@ -39,6 +39,8 @@ import DiscountConditionService from "./discount-condition"
import CustomerService from "./customer" import CustomerService from "./customer"
import { TransactionBaseService } from "../interfaces" import { TransactionBaseService } from "../interfaces"
import { buildQuery, setMetadata } from "../utils" import { buildQuery, setMetadata } from "../utils"
import { FlagRouter } from "../utils/flag-router"
import TaxInclusivePricingFeatureFlag from "../loaders/feature-flags/tax-inclusive-pricing"
/** /**
* Provides layer to manipulate discounts. * Provides layer to manipulate discounts.
@@ -58,6 +60,7 @@ class DiscountService extends TransactionBaseService {
protected readonly productService_: ProductService protected readonly productService_: ProductService
protected readonly regionService_: RegionService protected readonly regionService_: RegionService
protected readonly eventBus_: EventBusService protected readonly eventBus_: EventBusService
protected readonly featureFlagRouter_: FlagRouter
constructor({ constructor({
manager, manager,
@@ -71,6 +74,7 @@ class DiscountService extends TransactionBaseService {
regionService, regionService,
customerService, customerService,
eventBusService, eventBusService,
featureFlagRouter,
}) { }) {
// eslint-disable-next-line prefer-rest-params // eslint-disable-next-line prefer-rest-params
super(arguments[0]) super(arguments[0])
@@ -86,6 +90,7 @@ class DiscountService extends TransactionBaseService {
this.regionService_ = regionService this.regionService_ = regionService
this.customerService_ = customerService this.customerService_ = customerService
this.eventBus_ = eventBusService this.eventBus_ = eventBusService
this.featureFlagRouter_ = featureFlagRouter
} }
/** /**
@@ -579,7 +584,23 @@ class DiscountService extends TransactionBaseService {
const { type, value, allocation } = discount.rule const { type, value, allocation } = discount.rule
const fullItemPrice = lineItem.unit_price * lineItem.quantity let fullItemPrice = lineItem.unit_price * lineItem.quantity
if (
this.featureFlagRouter_.isFeatureEnabled(
TaxInclusivePricingFeatureFlag.key
) &&
lineItem.includes_tax
) {
const lineItemTotals = await this.totalsService_.getLineItemTotals(
lineItem,
cart,
{
include_tax: true,
exclude_gift_cards: true,
}
)
fullItemPrice = lineItemTotals.subtotal
}
if (type === DiscountRuleType.PERCENTAGE) { if (type === DiscountRuleType.PERCENTAGE) {
adjustment = Math.round((fullItemPrice / 100) * value) adjustment = Math.round((fullItemPrice / 100) * value)
@@ -590,12 +611,11 @@ class DiscountService extends TransactionBaseService {
// when a fixed discount should be applied to the total, // when a fixed discount should be applied to the total,
// we create line adjustments for each item with an amount // we create line adjustments for each item with an amount
// relative to the subtotal // relative to the subtotal
const subtotal = this.totalsService_.getSubtotal(cart, { const subtotal = await this.totalsService_.getSubtotal(cart, {
excludeNonDiscounts: true, excludeNonDiscounts: true,
}) })
const nominator = Math.min(value, subtotal) const nominator = Math.min(value, subtotal)
const itemRelativeToSubtotal = lineItem.unit_price / subtotal const totalItemPercentage = fullItemPrice / subtotal
const totalItemPercentage = itemRelativeToSubtotal * lineItem.quantity
adjustment = Math.round(nominator * totalItemPercentage) adjustment = Math.round(nominator * totalItemPercentage)
} else { } else {
adjustment = value * lineItem.quantity adjustment = value * lineItem.quantity
+1
View File
@@ -3,6 +3,7 @@ export { default as BatchJobService } from "./batch-job"
export { default as CartService } from "./cart" export { default as CartService } from "./cart"
export { default as ClaimItemService } from "./claim-item" export { default as ClaimItemService } from "./claim-item"
export { default as ClaimService } from "./claim" export { default as ClaimService } from "./claim"
export { default as CurrencyService } from "./currency"
export { default as CustomShippingOptionService } from "./custom-shipping-option" export { default as CustomShippingOptionService } from "./custom-shipping-option"
export { default as CustomerGroupService } from "./customer-group" export { default as CustomerGroupService } from "./customer-group"
export { default as CustomerService } from "./customer" export { default as CustomerService } from "./customer"
@@ -1,10 +1,13 @@
import { MedusaError } from "medusa-core-utils" import { MedusaError } from "medusa-core-utils"
import { BaseService } from "medusa-interfaces" import { BaseService } from "medusa-interfaces"
import { EntityManager } from "typeorm" import { EntityManager } from "typeorm"
import { Cart } from "../models/cart" import {
import { LineItem } from "../models/line-item" Cart,
import { LineItemAdjustment } from "../models/line-item-adjustment" DiscountRuleType,
import { ProductVariant } from "../models/product-variant" LineItem,
LineItemAdjustment,
ProductVariant,
} from "../models"
import { LineItemAdjustmentRepository } from "../repositories/line-item-adjustment" import { LineItemAdjustmentRepository } from "../repositories/line-item-adjustment"
import { FindConfig } from "../types/common" import { FindConfig } from "../types/common"
import { FilterableLineItemAdjustmentProps } from "../types/line-item-adjustment" import { FilterableLineItemAdjustmentProps } from "../types/line-item-adjustment"
@@ -70,8 +73,9 @@ class LineItemAdjustmentService extends BaseService {
id: string, id: string,
config: FindConfig<LineItemAdjustment> = {} config: FindConfig<LineItemAdjustment> = {}
): Promise<LineItemAdjustment> { ): Promise<LineItemAdjustment> {
const lineItemAdjustmentRepo: LineItemAdjustmentRepository = const lineItemAdjustmentRepo: LineItemAdjustmentRepository = this.manager_.getCustomRepository(
this.manager_.getCustomRepository(this.lineItemAdjustmentRepo_) this.lineItemAdjustmentRepo_
)
const query = this.buildQuery_({ id }, config) const query = this.buildQuery_({ id }, config)
const lineItemAdjustment = await lineItemAdjustmentRepo.findOne(query) const lineItemAdjustment = await lineItemAdjustmentRepo.findOne(query)
@@ -93,8 +97,9 @@ class LineItemAdjustmentService extends BaseService {
*/ */
async create(data: Partial<LineItemAdjustment>): Promise<LineItemAdjustment> { async create(data: Partial<LineItemAdjustment>): Promise<LineItemAdjustment> {
return await this.atomicPhase_(async (manager: EntityManager) => { return await this.atomicPhase_(async (manager: EntityManager) => {
const lineItemAdjustmentRepo: LineItemAdjustmentRepository = const lineItemAdjustmentRepo: LineItemAdjustmentRepository = manager.getCustomRepository(
manager.getCustomRepository(this.lineItemAdjustmentRepo_) this.lineItemAdjustmentRepo_
)
const lineItemAdjustment = lineItemAdjustmentRepo.create(data) const lineItemAdjustment = lineItemAdjustmentRepo.create(data)
@@ -113,8 +118,9 @@ class LineItemAdjustmentService extends BaseService {
data: Partial<LineItemAdjustment> data: Partial<LineItemAdjustment>
): Promise<LineItemAdjustment> { ): Promise<LineItemAdjustment> {
return await this.atomicPhase_(async (manager: EntityManager) => { return await this.atomicPhase_(async (manager: EntityManager) => {
const lineItemAdjustmentRepo: LineItemAdjustmentRepository = const lineItemAdjustmentRepo: LineItemAdjustmentRepository = manager.getCustomRepository(
manager.getCustomRepository(this.lineItemAdjustmentRepo_) this.lineItemAdjustmentRepo_
)
const lineItemAdjustment = await this.retrieve(id) const lineItemAdjustment = await this.retrieve(id)
@@ -163,8 +169,9 @@ class LineItemAdjustmentService extends BaseService {
selectorOrId: string | FilterableLineItemAdjustmentProps selectorOrId: string | FilterableLineItemAdjustmentProps
): Promise<void> { ): Promise<void> {
return this.atomicPhase_(async (manager) => { return this.atomicPhase_(async (manager) => {
const lineItemAdjustmentRepo: LineItemAdjustmentRepository = const lineItemAdjustmentRepo: LineItemAdjustmentRepository = manager.getCustomRepository(
manager.getCustomRepository(this.lineItemAdjustmentRepo_) this.lineItemAdjustmentRepo_
)
if (typeof selectorOrId === "string") { if (typeof selectorOrId === "string") {
return await this.delete({ id: selectorOrId }) return await this.delete({ id: selectorOrId })
@@ -206,7 +213,7 @@ class LineItemAdjustmentService extends BaseService {
} }
const [discount] = cart.discounts.filter( const [discount] = cart.discounts.filter(
(d) => d.rule.type !== "free_shipping" (d) => d.rule.type !== DiscountRuleType.FREE_SHIPPING
) )
// if no discount is applied to the cart then return // if no discount is applied to the cart then return
+31 -8
View File
@@ -2,21 +2,23 @@ import { MedusaError } from "medusa-core-utils"
import { BaseService } from "medusa-interfaces" import { BaseService } from "medusa-interfaces"
import { EntityManager } from "typeorm" import { EntityManager } from "typeorm"
import { DeepPartial } from "typeorm/common/DeepPartial" import { DeepPartial } from "typeorm/common/DeepPartial"
import TaxInclusivePricingFeatureFlag from "../loaders/feature-flags/tax-inclusive-pricing"
import { LineItemTaxLine } from "../models"
import { Cart } from "../models/cart"
import { LineItem } from "../models/line-item"
import { LineItemAdjustment } from "../models/line-item-adjustment"
import { CartRepository } from "../repositories/cart"
import { LineItemRepository } from "../repositories/line-item" import { LineItemRepository } from "../repositories/line-item"
import { LineItemTaxLineRepository } from "../repositories/line-item-tax-line" import { LineItemTaxLineRepository } from "../repositories/line-item-tax-line"
import { FindConfig } from "../types/common"
import { FlagRouter } from "../utils/flag-router"
import { import {
PricingService, PricingService,
ProductService, ProductService,
RegionService,
ProductVariantService, ProductVariantService,
RegionService,
} from "./index" } from "./index"
import { CartRepository } from "../repositories/cart"
import { LineItem } from "../models/line-item"
import LineItemAdjustmentService from "./line-item-adjustment" import LineItemAdjustmentService from "./line-item-adjustment"
import { Cart } from "../models/cart"
import { LineItemAdjustment } from "../models/line-item-adjustment"
import { FindConfig } from "../types/common"
import { LineItemTaxLine } from "../models"
type InjectedDependencies = { type InjectedDependencies = {
manager: EntityManager manager: EntityManager
@@ -28,6 +30,7 @@ type InjectedDependencies = {
pricingService: PricingService pricingService: PricingService
regionService: RegionService regionService: RegionService
lineItemAdjustmentService: LineItemAdjustmentService lineItemAdjustmentService: LineItemAdjustmentService
featureFlagRouter: FlagRouter
} }
/** /**
@@ -41,7 +44,9 @@ class LineItemService extends BaseService {
protected readonly cartRepository_: typeof CartRepository protected readonly cartRepository_: typeof CartRepository
protected readonly productVariantService_: ProductVariantService protected readonly productVariantService_: ProductVariantService
protected readonly productService_: ProductService protected readonly productService_: ProductService
protected readonly pricingService_: PricingService
protected readonly regionService_: RegionService protected readonly regionService_: RegionService
protected readonly featureFlagRouter_: FlagRouter
protected readonly lineItemAdjustmentService_: LineItemAdjustmentService protected readonly lineItemAdjustmentService_: LineItemAdjustmentService
constructor({ constructor({
@@ -54,6 +59,7 @@ class LineItemService extends BaseService {
regionService, regionService,
cartRepository, cartRepository,
lineItemAdjustmentService, lineItemAdjustmentService,
featureFlagRouter,
}: InjectedDependencies) { }: InjectedDependencies) {
super() super()
@@ -66,6 +72,7 @@ class LineItemService extends BaseService {
this.regionService_ = regionService this.regionService_ = regionService
this.cartRepository_ = cartRepository this.cartRepository_ = cartRepository
this.lineItemAdjustmentService_ = lineItemAdjustmentService this.lineItemAdjustmentService_ = lineItemAdjustmentService
this.featureFlagRouter_ = featureFlagRouter
} }
withTransaction(transactionManager: EntityManager): LineItemService { withTransaction(transactionManager: EntityManager): LineItemService {
@@ -83,6 +90,7 @@ class LineItemService extends BaseService {
regionService: this.regionService_, regionService: this.regionService_,
cartRepository: this.cartRepository_, cartRepository: this.cartRepository_,
lineItemAdjustmentService: this.lineItemAdjustmentService_, lineItemAdjustmentService: this.lineItemAdjustmentService_,
featureFlagRouter: this.featureFlagRouter_,
}) })
cloned.transactionManager_ = transactionManager cloned.transactionManager_ = transactionManager
@@ -197,6 +205,7 @@ class LineItemService extends BaseService {
quantity: number, quantity: number,
context: { context: {
unit_price?: number unit_price?: number
includes_tax?: boolean
metadata?: Record<string, unknown> metadata?: Record<string, unknown>
customer_id?: string customer_id?: string
cart?: Cart cart?: Cart
@@ -216,6 +225,9 @@ class LineItemService extends BaseService {
]) ])
let unit_price = Number(context.unit_price) < 0 ? 0 : context.unit_price let unit_price = Number(context.unit_price) < 0 ? 0 : context.unit_price
let unitPriceIncludesTax = false
let shouldMerge = false let shouldMerge = false
if (context.unit_price === undefined || context.unit_price === null) { if (context.unit_price === undefined || context.unit_price === null) {
@@ -228,7 +240,10 @@ class LineItemService extends BaseService {
customer_id: context?.customer_id, customer_id: context?.customer_id,
include_discount_prices: true, include_discount_prices: true,
}) })
unit_price = variantPricing.calculated_price
unitPriceIncludesTax = !!variantPricing.calculated_price_includes_tax
unit_price = variantPricing.calculated_price ?? undefined
} }
const rawLineItem: Partial<LineItem> = { const rawLineItem: Partial<LineItem> = {
@@ -244,6 +259,14 @@ class LineItemService extends BaseService {
should_merge: shouldMerge, should_merge: shouldMerge,
} }
if (
this.featureFlagRouter_.isFeatureEnabled(
TaxInclusivePricingFeatureFlag.key
)
) {
rawLineItem.includes_tax = unitPriceIncludesTax
}
const lineItemRepo = transactionManager.getCustomRepository( const lineItemRepo = transactionManager.getCustomRepository(
this.lineItemRepository_ this.lineItemRepository_
) )
+68 -40
View File
@@ -164,8 +164,9 @@ class OrderService extends TransactionBaseService {
const orderRepo = this.manager_.getCustomRepository(this.orderRepository_) const orderRepo = this.manager_.getCustomRepository(this.orderRepository_)
const query = buildQuery(selector, config) const query = buildQuery(selector, config)
const { select, relations, totalsToSelect } = const { select, relations, totalsToSelect } = this.transformQueryForTotals(
this.transformQueryForTotals(config) config
)
if (select && select.length) { if (select && select.length) {
query.select = select query.select = select
@@ -233,8 +234,9 @@ class OrderService extends TransactionBaseService {
} }
} }
const { select, relations, totalsToSelect } = const { select, relations, totalsToSelect } = this.transformQueryForTotals(
this.transformQueryForTotals(config) config
)
if (select && select.length) { if (select && select.length) {
query.select = select query.select = select
@@ -252,7 +254,9 @@ class OrderService extends TransactionBaseService {
return [orders, count] return [orders, count]
} }
protected transformQueryForTotals(config: FindConfig<Order>): { protected transformQueryForTotals(
config: FindConfig<Order>
): {
relations: string[] | undefined relations: string[] | undefined
select: FindConfig<Order>["select"] select: FindConfig<Order>["select"]
totalsToSelect: FindConfig<Order>["select"] totalsToSelect: FindConfig<Order>["select"]
@@ -333,8 +337,9 @@ class OrderService extends TransactionBaseService {
): Promise<Order> { ): Promise<Order> {
const orderRepo = this.manager_.getCustomRepository(this.orderRepository_) const orderRepo = this.manager_.getCustomRepository(this.orderRepository_)
const { select, relations, totalsToSelect } = const { select, relations, totalsToSelect } = this.transformQueryForTotals(
this.transformQueryForTotals(config) config
)
const query = { const query = {
where: { id: orderId }, where: { id: orderId },
@@ -373,8 +378,9 @@ class OrderService extends TransactionBaseService {
): Promise<Order> { ): Promise<Order> {
const orderRepo = this.manager_.getCustomRepository(this.orderRepository_) const orderRepo = this.manager_.getCustomRepository(this.orderRepository_)
const { select, relations, totalsToSelect } = const { select, relations, totalsToSelect } = this.transformQueryForTotals(
this.transformQueryForTotals(config) config
)
const query = { const query = {
where: { cart_id: cartId }, where: { cart_id: cartId },
@@ -412,8 +418,9 @@ class OrderService extends TransactionBaseService {
): Promise<Order> { ): Promise<Order> {
const orderRepo = this.manager_.getCustomRepository(this.orderRepository_) const orderRepo = this.manager_.getCustomRepository(this.orderRepository_)
const { select, relations, totalsToSelect } = const { select, relations, totalsToSelect } = this.transformQueryForTotals(
this.transformQueryForTotals(config) config
)
const query = { const query = {
where: { external_id: externalId }, where: { external_id: externalId },
@@ -851,8 +858,9 @@ class OrderService extends TransactionBaseService {
.withTransaction(manager) .withTransaction(manager)
.createShippingMethod(optionId, data ?? {}, { order, ...config }) .createShippingMethod(optionId, data ?? {}, { order, ...config })
const shippingOptionServiceTx = const shippingOptionServiceTx = this.shippingOptionService_.withTransaction(
this.shippingOptionService_.withTransaction(manager) manager
)
const methods = [newMethod] const methods = [newMethod]
if (shipping_methods.length) { if (shipping_methods.length) {
@@ -1023,8 +1031,9 @@ class OrderService extends TransactionBaseService {
await inventoryServiceTx.adjustInventory(item.variant_id, item.quantity) await inventoryServiceTx.adjustInventory(item.variant_id, item.quantity)
} }
const paymentProviderServiceTx = const paymentProviderServiceTx = this.paymentProviderService_.withTransaction(
this.paymentProviderService_.withTransaction(manager) manager
)
for (const p of order.payments) { for (const p of order.payments) {
await paymentProviderServiceTx.cancelPayment(p) await paymentProviderServiceTx.cancelPayment(p)
} }
@@ -1064,8 +1073,9 @@ class OrderService extends TransactionBaseService {
) )
} }
const paymentProviderServiceTx = const paymentProviderServiceTx = this.paymentProviderService_.withTransaction(
this.paymentProviderService_.withTransaction(manager) manager
)
const payments: Payment[] = [] const payments: Payment[] = []
for (const p of order.payments) { for (const p of order.payments) {
@@ -1218,7 +1228,7 @@ class OrderService extends TransactionBaseService {
const fulfillments = await this.fulfillmentService_ const fulfillments = await this.fulfillmentService_
.withTransaction(manager) .withTransaction(manager)
.createFulfillment( .createFulfillment(
order as unknown as CreateFulfillmentOrder, (order as unknown) as CreateFulfillmentOrder,
itemsToFulfill, itemsToFulfill,
{ {
metadata, metadata,
@@ -1429,17 +1439,23 @@ class OrderService extends TransactionBaseService {
for (const totalField of totalsFields) { for (const totalField of totalsFields) {
switch (totalField) { switch (totalField) {
case "shipping_total": { case "shipping_total": {
order.shipping_total = this.totalsService_.getShippingTotal(order) order.shipping_total = await this.totalsService_.getShippingTotal(
order
)
break break
} }
case "gift_card_total": { case "gift_card_total": {
const giftCardBreakdown = this.totalsService_.getGiftCardTotal(order) const giftCardBreakdown = await this.totalsService_.getGiftCardTotal(
order
)
order.gift_card_total = giftCardBreakdown.total order.gift_card_total = giftCardBreakdown.total
order.gift_card_tax_total = giftCardBreakdown.tax_total order.gift_card_tax_total = giftCardBreakdown.tax_total
break break
} }
case "discount_total": { case "discount_total": {
order.discount_total = this.totalsService_.getDiscountTotal(order) order.discount_total = await this.totalsService_.getDiscountTotal(
order
)
break break
} }
case "tax_total": { case "tax_total": {
@@ -1447,7 +1463,7 @@ class OrderService extends TransactionBaseService {
break break
} }
case "subtotal": { case "subtotal": {
order.subtotal = this.totalsService_.getSubtotal(order) order.subtotal = await this.totalsService_.getSubtotal(order)
break break
} }
case "total": { case "total": {
@@ -1471,36 +1487,48 @@ class OrderService extends TransactionBaseService {
break break
} }
case "items.refundable": { case "items.refundable": {
order.items = order.items.map((i) => ({ const items: LineItem[] = []
...i, for (const item of order.items) {
refundable: this.totalsService_.getLineItemRefund(order, { items.push({
...i, ...item,
quantity: i.quantity - (i.returned_quantity || 0), refundable: await this.totalsService_.getLineItemRefund(order, {
...item,
quantity: item.quantity - (item.returned_quantity || 0),
} as LineItem), } as LineItem),
})) as LineItem[] } as LineItem)
}
order.items = items
break break
} }
case "swaps.additional_items.refundable": { case "swaps.additional_items.refundable": {
for (const s of order.swaps) { for (const s of order.swaps) {
s.additional_items = s.additional_items.map((i) => ({ const items: LineItem[] = []
...i, for (const item of s.additional_items) {
refundable: this.totalsService_.getLineItemRefund(order, { items.push({
...i, ...item,
quantity: i.quantity - (i.returned_quantity || 0), refundable: await this.totalsService_.getLineItemRefund(order, {
...item,
quantity: item.quantity - (item.returned_quantity || 0),
} as LineItem), } as LineItem),
})) as LineItem[] } as LineItem)
}
s.additional_items = items
} }
break break
} }
case "claims.additional_items.refundable": { case "claims.additional_items.refundable": {
for (const c of order.claims) { for (const c of order.claims) {
c.additional_items = c.additional_items.map((i) => ({ const items: LineItem[] = []
...i, for (const item of c.additional_items) {
refundable: this.totalsService_.getLineItemRefund(order, { items.push({
...i, ...item,
quantity: i.quantity - (i.returned_quantity || 0), refundable: await this.totalsService_.getLineItemRefund(order, {
...item,
quantity: item.quantity - (item.returned_quantity || 0),
} as LineItem), } as LineItem),
})) as LineItem[] } as LineItem)
}
c.additional_items = items
} }
break break
} }
+40 -10
View File
@@ -1,5 +1,5 @@
import { MedusaError } from "medusa-core-utils" import { MedusaError } from "medusa-core-utils"
import { EntityManager, FindOperator } from "typeorm" import { DeepPartial, EntityManager, FindOperator } from "typeorm"
import { CustomerGroupService } from "." import { CustomerGroupService } from "."
import { CustomerGroup, PriceList, Product, ProductVariant } from "../models" import { CustomerGroup, PriceList, Product, ProductVariant } from "../models"
import { MoneyAmountRepository } from "../repositories/money-amount" import { MoneyAmountRepository } from "../repositories/money-amount"
@@ -24,6 +24,8 @@ import { FilterableProductProps } from "../types/product"
import ProductVariantService from "./product-variant" import ProductVariantService from "./product-variant"
import { FilterableProductVariantProps } from "../types/product-variant" import { FilterableProductVariantProps } from "../types/product-variant"
import { ProductVariantRepository } from "../repositories/product-variant" import { ProductVariantRepository } from "../repositories/product-variant"
import { FlagRouter } from "../utils/flag-router"
import TaxInclusivePricingFeatureFlag from "../loaders/feature-flags/tax-inclusive-pricing"
type PriceListConstructorProps = { type PriceListConstructorProps = {
manager: EntityManager manager: EntityManager
@@ -34,6 +36,7 @@ type PriceListConstructorProps = {
priceListRepository: typeof PriceListRepository priceListRepository: typeof PriceListRepository
moneyAmountRepository: typeof MoneyAmountRepository moneyAmountRepository: typeof MoneyAmountRepository
productVariantRepository: typeof ProductVariantRepository productVariantRepository: typeof ProductVariantRepository
featureFlagRouter: FlagRouter
} }
/** /**
@@ -51,6 +54,7 @@ class PriceListService extends TransactionBaseService {
protected readonly priceListRepo_: typeof PriceListRepository protected readonly priceListRepo_: typeof PriceListRepository
protected readonly moneyAmountRepo_: typeof MoneyAmountRepository protected readonly moneyAmountRepo_: typeof MoneyAmountRepository
protected readonly productVariantRepo_: typeof ProductVariantRepository protected readonly productVariantRepo_: typeof ProductVariantRepository
protected readonly featureFlagRouter_: FlagRouter
constructor({ constructor({
manager, manager,
@@ -61,6 +65,7 @@ class PriceListService extends TransactionBaseService {
priceListRepository, priceListRepository,
moneyAmountRepository, moneyAmountRepository,
productVariantRepository, productVariantRepository,
featureFlagRouter,
}: PriceListConstructorProps) { }: PriceListConstructorProps) {
// eslint-disable-next-line prefer-rest-params // eslint-disable-next-line prefer-rest-params
super(arguments[0]) super(arguments[0])
@@ -73,6 +78,7 @@ class PriceListService extends TransactionBaseService {
this.priceListRepo_ = priceListRepository this.priceListRepo_ = priceListRepository
this.moneyAmountRepo_ = moneyAmountRepository this.moneyAmountRepo_ = moneyAmountRepository
this.productVariantRepo_ = productVariantRepository this.productVariantRepo_ = productVariantRepository
this.featureFlagRouter_ = featureFlagRouter
} }
/** /**
@@ -102,18 +108,34 @@ class PriceListService extends TransactionBaseService {
/** /**
* Creates a Price List * Creates a Price List
* @param {CreatePriceListInput} priceListObject - the Price List to create * @param priceListObject - the Price List to create
* @return {Promise<PriceList>} created Price List * @return created Price List
*/ */
async create(priceListObject: CreatePriceListInput): Promise<PriceList> { async create(
priceListObject: CreatePriceListInput
): Promise<PriceList | never> {
return await this.atomicPhase_(async (manager: EntityManager) => { return await this.atomicPhase_(async (manager: EntityManager) => {
const priceListRepo = manager.getCustomRepository(this.priceListRepo_) const priceListRepo = manager.getCustomRepository(this.priceListRepo_)
const moneyAmountRepo = manager.getCustomRepository(this.moneyAmountRepo_) const moneyAmountRepo = manager.getCustomRepository(this.moneyAmountRepo_)
const { prices, customer_groups, ...rest } = priceListObject const { prices, customer_groups, includes_tax, ...rest } = priceListObject
try { try {
const entity = priceListRepo.create(rest) const rawPriceList: DeepPartial<PriceList> = {
...rest,
}
if (
this.featureFlagRouter_.isFeatureEnabled(
TaxInclusivePricingFeatureFlag.key
)
) {
if (typeof includes_tax !== "undefined") {
rawPriceList.includes_tax = includes_tax
}
}
const entity = priceListRepo.create(rawPriceList)
const priceList = await priceListRepo.save(entity) const priceList = await priceListRepo.save(entity)
@@ -125,11 +147,9 @@ class PriceListService extends TransactionBaseService {
await this.upsertCustomerGroups_(priceList.id, customer_groups) await this.upsertCustomerGroups_(priceList.id, customer_groups)
} }
const result = await this.retrieve(priceList.id, { return await this.retrieve(priceList.id, {
relations: ["prices", "customer_groups"], relations: ["prices", "customer_groups"],
}) })
return result
} catch (error) { } catch (error) {
throw formatException(error) throw formatException(error)
} }
@@ -149,7 +169,17 @@ class PriceListService extends TransactionBaseService {
const priceList = await this.retrieve(id, { select: ["id"] }) const priceList = await this.retrieve(id, { select: ["id"] })
const { prices, customer_groups, ...rest } = update const { prices, customer_groups, includes_tax, ...rest } = update
if (
this.featureFlagRouter_.isFeatureEnabled(
TaxInclusivePricingFeatureFlag.key
)
) {
if (typeof includes_tax !== "undefined") {
priceList.includes_tax = includes_tax
}
}
if (prices) { if (prices) {
const prices_ = await this.addCurrencyFromRegion(prices) const prices_ = await this.addCurrencyFromRegion(prices)
+74 -21
View File
@@ -1,21 +1,24 @@
import { EntityManager } from "typeorm"
import { MedusaError } from "medusa-core-utils" import { MedusaError } from "medusa-core-utils"
import { EntityManager } from "typeorm"
import { ProductVariantService, RegionService, TaxProviderService } from "." import { ProductVariantService, RegionService, TaxProviderService } from "."
import { Product, ProductVariant, ShippingOption } from "../models"
import { TaxServiceRate } from "../types/tax-service"
import {
ProductVariantPricing,
TaxedPricing,
PricingContext,
PricedProduct,
PricedShippingOption,
PricedVariant,
} from "../types/pricing"
import { TransactionBaseService } from "../interfaces" import { TransactionBaseService } from "../interfaces"
import { import {
IPriceSelectionStrategy, IPriceSelectionStrategy,
PriceSelectionContext, PriceSelectionContext,
} from "../interfaces/price-selection-strategy" } from "../interfaces/price-selection-strategy"
import TaxInclusivePricingFeatureFlag from "../loaders/feature-flags/tax-inclusive-pricing"
import { Product, ProductVariant, ShippingOption } from "../models"
import {
PricedProduct,
PricedShippingOption,
PricedVariant,
PricingContext,
ProductVariantPricing,
TaxedPricing,
} from "../types/pricing"
import { TaxServiceRate } from "../types/tax-service"
import { calculatePriceTaxAmount } from "../utils"
import { FlagRouter } from "../utils/flag-router"
type InjectedDependencies = { type InjectedDependencies = {
manager: EntityManager manager: EntityManager
@@ -23,6 +26,7 @@ type InjectedDependencies = {
taxProviderService: TaxProviderService taxProviderService: TaxProviderService
regionService: RegionService regionService: RegionService
priceSelectionStrategy: IPriceSelectionStrategy priceSelectionStrategy: IPriceSelectionStrategy
featureFlagRouter: FlagRouter
} }
/** /**
@@ -36,6 +40,7 @@ class PricingService extends TransactionBaseService {
protected readonly taxProviderService: TaxProviderService protected readonly taxProviderService: TaxProviderService
protected readonly priceSelectionStrategy: IPriceSelectionStrategy protected readonly priceSelectionStrategy: IPriceSelectionStrategy
protected readonly productVariantService: ProductVariantService protected readonly productVariantService: ProductVariantService
protected readonly featureFlagRouter: FlagRouter
constructor({ constructor({
manager, manager,
@@ -43,6 +48,7 @@ class PricingService extends TransactionBaseService {
taxProviderService, taxProviderService,
regionService, regionService,
priceSelectionStrategy, priceSelectionStrategy,
featureFlagRouter,
}: InjectedDependencies) { }: InjectedDependencies) {
// eslint-disable-next-line prefer-rest-params // eslint-disable-next-line prefer-rest-params
super(arguments[0]) super(arguments[0])
@@ -52,6 +58,7 @@ class PricingService extends TransactionBaseService {
this.taxProviderService = taxProviderService this.taxProviderService = taxProviderService
this.priceSelectionStrategy = priceSelectionStrategy this.priceSelectionStrategy = priceSelectionStrategy
this.productVariantService = productVariantService this.productVariantService = productVariantService
this.featureFlagRouter = featureFlagRouter
} }
/** /**
@@ -115,17 +122,43 @@ class PricingService extends TransactionBaseService {
} }
if (variantPricing.calculated_price !== null) { if (variantPricing.calculated_price !== null) {
const taxAmount = Math.round(variantPricing.calculated_price * rate) const includesTax = !!(
taxedPricing.calculated_tax = taxAmount this.featureFlagRouter.isFeatureEnabled(
TaxInclusivePricingFeatureFlag.key
) && variantPricing.calculated_price_includes_tax
)
taxedPricing.calculated_tax = Math.round(
calculatePriceTaxAmount({
price: variantPricing.calculated_price,
taxRate: rate,
includesTax,
})
)
taxedPricing.calculated_price_incl_tax = taxedPricing.calculated_price_incl_tax =
variantPricing.calculated_price + taxAmount variantPricing.calculated_price_includes_tax
? variantPricing.calculated_price
: variantPricing.calculated_price + taxedPricing.calculated_tax
} }
if (variantPricing.original_price !== null) { if (variantPricing.original_price !== null) {
const taxAmount = Math.round(variantPricing.original_price * rate) const includesTax = !!(
taxedPricing.original_tax = taxAmount this.featureFlagRouter.isFeatureEnabled(
TaxInclusivePricingFeatureFlag.key
) && variantPricing.original_price_includes_tax
)
taxedPricing.original_tax = Math.round(
calculatePriceTaxAmount({
price: variantPricing.original_price,
taxRate: rate,
includesTax,
})
)
taxedPricing.original_price_incl_tax = taxedPricing.original_price_incl_tax =
variantPricing.original_price + taxAmount variantPricing.original_price_includes_tax
? variantPricing.original_price
: variantPricing.original_price + taxedPricing.original_tax
} }
return taxedPricing return taxedPricing
@@ -137,6 +170,9 @@ class PricingService extends TransactionBaseService {
context: PricingContext context: PricingContext
): Promise<ProductVariantPricing> { ): Promise<ProductVariantPricing> {
const transactionManager = this.transactionManager_ ?? this.manager_ const transactionManager = this.transactionManager_ ?? this.manager_
context.price_selection.tax_rates = taxRates
const pricing = await this.priceSelectionStrategy const pricing = await this.priceSelectionStrategy
.withTransaction(transactionManager) .withTransaction(transactionManager)
.calculateVariantPrice(variantId, context.price_selection) .calculateVariantPrice(variantId, context.price_selection)
@@ -146,6 +182,8 @@ class PricingService extends TransactionBaseService {
original_price: pricing.originalPrice, original_price: pricing.originalPrice,
calculated_price: pricing.calculatedPrice, calculated_price: pricing.calculatedPrice,
calculated_price_type: pricing.calculatedPriceType, calculated_price_type: pricing.calculatedPriceType,
original_price_includes_tax: pricing.originalPriceIncludesTax,
calculated_price_includes_tax: pricing.calculatedPriceIncludesTax,
original_price_incl_tax: null, original_price_incl_tax: null,
calculated_price_incl_tax: null, calculated_price_incl_tax: null,
original_tax: null, original_tax: null,
@@ -418,14 +456,29 @@ class PricingService extends TransactionBaseService {
}, },
0 0
) )
const tax = Math.round(price * rate)
const total = price + tax
return { const includesTax =
this.featureFlagRouter.isFeatureEnabled(
TaxInclusivePricingFeatureFlag.key
) && shippingOption.includes_tax
const taxAmount = Math.round(
calculatePriceTaxAmount({
taxRate: rate,
price,
includesTax,
})
)
const totalInclTax = includesTax ? price : price + taxAmount
const result: PricedShippingOption = {
...shippingOption, ...shippingOption,
price_incl_tax: total, price_incl_tax: totalInclTax,
tax_rates: shippingOptionRates, tax_rates: shippingOptionRates,
tax_amount: taxAmount,
} }
return result
} }
/** /**
+37 -9
View File
@@ -2,22 +2,24 @@ import { DeepPartial, EntityManager } from "typeorm"
import { MedusaError } from "medusa-core-utils" import { MedusaError } from "medusa-core-utils"
import StoreService from "./store"
import EventBusService from "./event-bus"
import { countries } from "../utils/countries"
import { TransactionBaseService } from "../interfaces" import { TransactionBaseService } from "../interfaces"
import { RegionRepository } from "../repositories/region" import TaxInclusivePricingFeatureFlag from "../loaders/feature-flags/tax-inclusive-pricing"
import { Country, Currency, Region } from "../models"
import { CountryRepository } from "../repositories/country" import { CountryRepository } from "../repositories/country"
import { CurrencyRepository } from "../repositories/currency" import { CurrencyRepository } from "../repositories/currency"
import { PaymentProviderRepository } from "../repositories/payment-provider"
import { FulfillmentProviderRepository } from "../repositories/fulfillment-provider" import { FulfillmentProviderRepository } from "../repositories/fulfillment-provider"
import { PaymentProviderRepository } from "../repositories/payment-provider"
import { RegionRepository } from "../repositories/region"
import { TaxProviderRepository } from "../repositories/tax-provider" import { TaxProviderRepository } from "../repositories/tax-provider"
import FulfillmentProviderService from "./fulfillment-provider"
import { Country, Currency, Region } from "../models"
import { FindConfig, Selector } from "../types/common" import { FindConfig, Selector } from "../types/common"
import { CreateRegionInput, UpdateRegionInput } from "../types/region" import { CreateRegionInput, UpdateRegionInput } from "../types/region"
import { buildQuery, setMetadata } from "../utils" import { buildQuery, setMetadata } from "../utils"
import { countries } from "../utils/countries"
import { FlagRouter } from "../utils/flag-router"
import EventBusService from "./event-bus"
import FulfillmentProviderService from "./fulfillment-provider"
import { PaymentProviderService } from "./index" import { PaymentProviderService } from "./index"
import StoreService from "./store"
type InjectedDependencies = { type InjectedDependencies = {
manager: EntityManager manager: EntityManager
@@ -25,6 +27,7 @@ type InjectedDependencies = {
eventBusService: EventBusService eventBusService: EventBusService
paymentProviderService: PaymentProviderService paymentProviderService: PaymentProviderService
fulfillmentProviderService: FulfillmentProviderService fulfillmentProviderService: FulfillmentProviderService
featureFlagRouter: FlagRouter
regionRepository: typeof RegionRepository regionRepository: typeof RegionRepository
countryRepository: typeof CountryRepository countryRepository: typeof CountryRepository
@@ -47,6 +50,7 @@ class RegionService extends TransactionBaseService {
protected manager_: EntityManager protected manager_: EntityManager
protected transactionManager_: EntityManager | undefined protected transactionManager_: EntityManager | undefined
protected featureFlagRouter_: FlagRouter
protected readonly eventBus_: EventBusService protected readonly eventBus_: EventBusService
protected readonly storeService_: StoreService protected readonly storeService_: StoreService
@@ -71,6 +75,7 @@ class RegionService extends TransactionBaseService {
taxProviderRepository, taxProviderRepository,
paymentProviderService, paymentProviderService,
fulfillmentProviderService, fulfillmentProviderService,
featureFlagRouter,
}: InjectedDependencies) { }: InjectedDependencies) {
super({ super({
manager, manager,
@@ -84,6 +89,7 @@ class RegionService extends TransactionBaseService {
taxProviderRepository, taxProviderRepository,
paymentProviderService, paymentProviderService,
fulfillmentProviderService, fulfillmentProviderService,
featureFlagRouter,
}) })
this.manager_ = manager this.manager_ = manager
@@ -97,6 +103,8 @@ class RegionService extends TransactionBaseService {
this.paymentProviderService_ = paymentProviderService this.paymentProviderService_ = paymentProviderService
this.taxProviderRepository_ = taxProviderRepository this.taxProviderRepository_ = taxProviderRepository
this.fulfillmentProviderService_ = fulfillmentProviderService this.fulfillmentProviderService_ = fulfillmentProviderService
this.featureFlagRouter_ = featureFlagRouter
} }
/** /**
@@ -115,10 +123,20 @@ class RegionService extends TransactionBaseService {
) )
const regionObject = { ...data } as DeepPartial<Region> const regionObject = { ...data } as DeepPartial<Region>
const { metadata, currency_code, ...toValidate } = data const { metadata, currency_code, includes_tax, ...toValidate } = data
const validated = await this.validateFields(toValidate) const validated = await this.validateFields(toValidate)
if (
this.featureFlagRouter_.isFeatureEnabled(
TaxInclusivePricingFeatureFlag.key
)
) {
if (typeof includes_tax !== "undefined") {
regionObject.includes_tax = includes_tax
}
}
if (currency_code) { if (currency_code) {
// will throw if currency is not added to store currencies // will throw if currency is not added to store currencies
await this.validateCurrency(currency_code) await this.validateCurrency(currency_code)
@@ -179,10 +197,20 @@ class RegionService extends TransactionBaseService {
const region = await this.retrieve(regionId) const region = await this.retrieve(regionId)
const { metadata, currency_code, ...toValidate } = update const { metadata, currency_code, includes_tax, ...toValidate } = update
const validated = await this.validateFields(toValidate, region.id) const validated = await this.validateFields(toValidate, region.id)
if (
this.featureFlagRouter_.isFeatureEnabled(
TaxInclusivePricingFeatureFlag.key
)
) {
if (typeof includes_tax !== "undefined") {
region.includes_tax = includes_tax
}
}
if (currency_code) { if (currency_code) {
// will throw if currency is not added to store currencies // will throw if currency is not added to store currencies
await this.validateCurrency(currency_code) await this.validateCurrency(currency_code)
+2 -2
View File
@@ -415,7 +415,7 @@ class ReturnService extends TransactionBaseService {
} }
} else { } else {
// Merchant hasn't specified refund amount so we calculate it // Merchant hasn't specified refund amount so we calculate it
toRefund = this.totalsService_.getRefundTotal(order, returnLines) toRefund = await this.totalsService_.getRefundTotal(order, returnLines)
} }
const method = data.shipping_method const method = data.shipping_method
@@ -469,7 +469,7 @@ class ReturnService extends TransactionBaseService {
) )
const calculationContext = const calculationContext =
this.totalsService_.getCalculationContext(order) await this.totalsService_.getCalculationContext(order)
const taxLines = await this.taxProviderService_ const taxLines = await this.taxProviderService_
.withTransaction(manager) .withTransaction(manager)
@@ -22,6 +22,18 @@ import {
import { buildQuery, isDefined, setMetadata } from "../utils" import { buildQuery, isDefined, setMetadata } from "../utils"
import FulfillmentProviderService from "./fulfillment-provider" import FulfillmentProviderService from "./fulfillment-provider"
import RegionService from "./region" import RegionService from "./region"
import { FlagRouter } from "../utils/flag-router"
import TaxInclusivePricingFeatureFlag from "../loaders/feature-flags/tax-inclusive-pricing"
type InjectedDependencies = {
manager: EntityManager
fulfillmentProviderService: FulfillmentProviderService
regionService: RegionService
shippingOptionRequirementRepository: typeof ShippingOptionRequirementRepository
shippingOptionRepository: typeof ShippingOptionRepository
shippingMethodRepository: typeof ShippingMethodRepository
featureFlagRouter: FlagRouter
}
/** /**
* Provides layer to manipulate profiles. * Provides layer to manipulate profiles.
@@ -32,6 +44,7 @@ class ShippingOptionService extends TransactionBaseService {
protected readonly requirementRepository_: typeof ShippingOptionRequirementRepository protected readonly requirementRepository_: typeof ShippingOptionRequirementRepository
protected readonly optionRepository_: typeof ShippingOptionRepository protected readonly optionRepository_: typeof ShippingOptionRepository
protected readonly methodRepository_: typeof ShippingMethodRepository protected readonly methodRepository_: typeof ShippingMethodRepository
protected readonly featureFlagRouter_: FlagRouter
protected manager_: EntityManager protected manager_: EntityManager
protected transactionManager_: EntityManager | undefined protected transactionManager_: EntityManager | undefined
@@ -43,7 +56,8 @@ class ShippingOptionService extends TransactionBaseService {
shippingMethodRepository, shippingMethodRepository,
fulfillmentProviderService, fulfillmentProviderService,
regionService, regionService,
}) { featureFlagRouter,
}: InjectedDependencies) {
// eslint-disable-next-line prefer-rest-params // eslint-disable-next-line prefer-rest-params
super(arguments[0]) super(arguments[0])
@@ -53,6 +67,7 @@ class ShippingOptionService extends TransactionBaseService {
this.requirementRepository_ = shippingOptionRequirementRepository this.requirementRepository_ = shippingOptionRequirementRepository
this.providerService_ = fulfillmentProviderService this.providerService_ = fulfillmentProviderService
this.regionService_ = regionService this.regionService_ = regionService
this.featureFlagRouter_ = featureFlagRouter
} }
/** /**
@@ -285,6 +300,16 @@ class ShippingOptionService extends TransactionBaseService {
price: methodPrice, price: methodPrice,
} }
if (
this.featureFlagRouter_.isFeatureEnabled(
TaxInclusivePricingFeatureFlag.key
)
) {
if (typeof option.includes_tax !== "undefined") {
toCreate.includes_tax = option.includes_tax
}
}
if (config.order) { if (config.order) {
toCreate.order_id = config.order.id toCreate.order_id = config.order.id
} }
@@ -313,10 +338,10 @@ class ShippingOptionService extends TransactionBaseService {
const created = await methodRepo.save(method) const created = await methodRepo.save(method)
return methodRepo.findOne({ return (await methodRepo.findOne({
where: { id: created.id }, where: { id: created.id },
relations: ["shipping_option"], relations: ["shipping_option"],
}) as unknown as ShippingMethod })) as ShippingMethod
}) })
} }
@@ -405,6 +430,16 @@ class ShippingOptionService extends TransactionBaseService {
option.amount = option.amount =
data.price_type === "calculated" ? null : data.amount ?? null data.price_type === "calculated" ? null : data.amount ?? null
if (
this.featureFlagRouter_.isFeatureEnabled(
TaxInclusivePricingFeatureFlag.key
)
) {
if (typeof data.includes_tax !== "undefined") {
option.includes_tax = data.includes_tax
}
}
const isValid = await this.providerService_.validateOption(option) const isValid = await this.providerService_.validateOption(option)
if (!isValid) { if (!isValid) {
@@ -584,6 +619,16 @@ class ShippingOptionService extends TransactionBaseService {
option.admin_only = update.admin_only option.admin_only = update.admin_only
} }
if (
this.featureFlagRouter_.isFeatureEnabled(
TaxInclusivePricingFeatureFlag.key
)
) {
if (typeof update.includes_tax !== "undefined") {
option.includes_tax = update.includes_tax
}
}
const optionRepo = manager.getCustomRepository(this.optionRepository_) const optionRepo = manager.getCustomRepository(this.optionRepository_)
return await optionRepo.save(option) return await optionRepo.save(option)
}) })
+2 -2
View File
@@ -1,13 +1,13 @@
import { MedusaError } from "medusa-core-utils" import { MedusaError } from "medusa-core-utils"
import { EntityManager } from "typeorm" import { EntityManager } from "typeorm"
import { TransactionBaseService } from "../interfaces" import { TransactionBaseService } from "../interfaces"
import { Store } from "../models" import { Currency, Store } from "../models"
import { CurrencyRepository } from "../repositories/currency" import { CurrencyRepository } from "../repositories/currency"
import { StoreRepository } from "../repositories/store" import { StoreRepository } from "../repositories/store"
import { FindConfig } from "../types/common" import { FindConfig } from "../types/common"
import { UpdateStoreInput } from "../types/store" import { UpdateStoreInput } from "../types/store"
import { buildQuery, setMetadata } from "../utils" import { buildQuery, setMetadata } from "../utils"
import { currencies, Currency } from "../utils/currencies" import { currencies } from "../utils/currencies"
import EventBusService from "./event-bus" import EventBusService from "./event-bus"
type InjectedDependencies = { type InjectedDependencies = {
+220 -99
View File
@@ -1,18 +1,19 @@
import { MedusaError } from "medusa-core-utils" import { MedusaError } from "medusa-core-utils"
import { BaseService } from "medusa-interfaces"
import { import {
ITaxCalculationStrategy, ITaxCalculationStrategy,
TaxCalculationContext, TaxCalculationContext,
TransactionBaseService, TransactionBaseService,
} from "../interfaces" } from "../interfaces"
import { Cart } from "../models/cart" import {
import { Discount } from "../models/discount" Cart,
import { DiscountRuleType } from "../models/discount-rule" Discount,
import { LineItem } from "../models/line-item" DiscountRuleType,
import { LineItemTaxLine } from "../models/line-item-tax-line" LineItem,
import { Order } from "../models/order" LineItemTaxLine,
import { ShippingMethod } from "../models/shipping-method" Order,
import { ShippingMethodTaxLine } from "../models/shipping-method-tax-line" ShippingMethod,
ShippingMethodTaxLine,
} from "../models"
import { isCart } from "../types/cart" import { isCart } from "../types/cart"
import { isOrder } from "../types/orders" import { isOrder } from "../types/orders"
import { import {
@@ -23,12 +24,16 @@ import {
} from "../types/totals" } from "../types/totals"
import TaxProviderService from "./tax-provider" import TaxProviderService from "./tax-provider"
import { EntityManager } from "typeorm" import { EntityManager } from "typeorm"
import { isDefined } from "../utils"
import { calculatePriceTaxAmount, isDefined } from "../utils"
import TaxInclusivePricingFeatureFlag from "../loaders/feature-flags/tax-inclusive-pricing"
import { FlagRouter } from "../utils/flag-router"
type ShippingMethodTotals = { type ShippingMethodTotals = {
price: number price: number
tax_total: number tax_total: number
total: number total: number
subtotal: number
original_total: number original_total: number
original_tax_total: number original_tax_total: number
tax_lines: ShippingMethodTaxLine[] tax_lines: ShippingMethodTaxLine[]
@@ -55,6 +60,7 @@ type LineItemTotals = {
type LineItemTotalsOptions = { type LineItemTotalsOptions = {
include_tax?: boolean include_tax?: boolean
use_tax_lines?: boolean use_tax_lines?: boolean
exclude_gift_cards?: boolean
} }
type GetLineItemTotalOptions = { type GetLineItemTotalOptions = {
@@ -67,6 +73,7 @@ type TotalsServiceProps = {
taxProviderService: TaxProviderService taxProviderService: TaxProviderService
taxCalculationStrategy: ITaxCalculationStrategy taxCalculationStrategy: ITaxCalculationStrategy
manager: EntityManager manager: EntityManager
featureFlagRouter: FlagRouter
} }
type GetTotalsOptions = { type GetTotalsOptions = {
@@ -94,24 +101,29 @@ class TotalsService extends TransactionBaseService {
protected manager_: EntityManager protected manager_: EntityManager
protected transactionManager_: EntityManager protected transactionManager_: EntityManager
private taxProviderService_: TaxProviderService protected readonly taxProviderService_: TaxProviderService
private taxCalculationStrategy_: ITaxCalculationStrategy protected readonly taxCalculationStrategy_: ITaxCalculationStrategy
protected readonly featureFlagRouter_: FlagRouter
constructor({ constructor({
manager, manager,
taxProviderService, taxProviderService,
taxCalculationStrategy, taxCalculationStrategy,
featureFlagRouter,
}: TotalsServiceProps) { }: TotalsServiceProps) {
super({ super({
taxProviderService, taxProviderService,
taxCalculationStrategy, taxCalculationStrategy,
manager, manager,
featureFlagRouter,
}) })
this.manager_ = manager this.manager_ = manager
this.taxProviderService_ = taxProviderService this.taxProviderService_ = taxProviderService
this.taxCalculationStrategy_ = taxCalculationStrategy this.taxCalculationStrategy_ = taxCalculationStrategy
this.manager_ = manager this.manager_ = manager
this.featureFlagRouter_ = featureFlagRouter
} }
/** /**
@@ -124,14 +136,14 @@ class TotalsService extends TransactionBaseService {
cartOrOrder: Cart | Order, cartOrOrder: Cart | Order,
options: GetTotalsOptions = {} options: GetTotalsOptions = {}
): Promise<number> { ): Promise<number> {
const subtotal = this.getSubtotal(cartOrOrder) const subtotal = await this.getSubtotal(cartOrOrder)
const taxTotal = const taxTotal =
(await this.getTaxTotal(cartOrOrder, options.force_taxes)) || 0 (await this.getTaxTotal(cartOrOrder, options.force_taxes)) || 0
const discountTotal = this.getDiscountTotal(cartOrOrder) const discountTotal = await this.getDiscountTotal(cartOrOrder)
const giftCardTotal = options.exclude_gift_cards const giftCardTotal = options.exclude_gift_cards
? { total: 0 } ? { total: 0 }
: this.getGiftCardTotal(cartOrOrder) : await this.getGiftCardTotal(cartOrOrder)
const shippingTotal = this.getShippingTotal(cartOrOrder) const shippingTotal = await this.getShippingTotal(cartOrOrder)
return ( return (
subtotal + taxTotal + shippingTotal - discountTotal - giftCardTotal.total subtotal + taxTotal + shippingTotal - discountTotal - giftCardTotal.total
@@ -182,7 +194,7 @@ class TotalsService extends TransactionBaseService {
cartOrOrder: Cart | Order, cartOrOrder: Cart | Order,
opts: GetShippingMethodTotalsOptions = {} opts: GetShippingMethodTotalsOptions = {}
): Promise<ShippingMethodTotals> { ): Promise<ShippingMethodTotals> {
const calculationContext = this.getCalculationContext(cartOrOrder, { const calculationContext = await this.getCalculationContext(cartOrOrder, {
exclude_shipping: true, exclude_shipping: true,
}) })
calculationContext.shipping_methods = [shippingMethod] calculationContext.shipping_methods = [shippingMethod]
@@ -191,64 +203,70 @@ class TotalsService extends TransactionBaseService {
price: shippingMethod.price, price: shippingMethod.price,
original_total: shippingMethod.price, original_total: shippingMethod.price,
total: shippingMethod.price, total: shippingMethod.price,
subtotal: shippingMethod.price,
original_tax_total: 0, original_tax_total: 0,
tax_total: 0, tax_total: 0,
tax_lines: shippingMethod.tax_lines || [], tax_lines: shippingMethod.tax_lines || [],
} }
if (opts.include_tax) { if (opts.include_tax) {
if (isOrder(cartOrOrder) && cartOrOrder.tax_rate !== null) { if (isOrder(cartOrOrder) && cartOrOrder.tax_rate != null) {
totals.original_tax_total = Math.round( totals.original_tax_total = Math.round(
totals.original_tax_total * (cartOrOrder.tax_rate / 100) totals.price * (cartOrOrder.tax_rate / 100)
) )
totals.tax_total = Math.round( totals.tax_total = Math.round(
totals.original_tax_total * (cartOrOrder.tax_rate / 100) totals.price * (cartOrOrder.tax_rate / 100)
) )
} else { } else if (totals.tax_lines.length === 0) {
let taxLines: ShippingMethodTaxLine[]
if (opts.use_tax_lines || isOrder(cartOrOrder)) {
if (typeof shippingMethod.tax_lines === "undefined") {
throw new MedusaError(
MedusaError.Types.UNEXPECTED_STATE,
"Tax Lines must be joined on shipping method to calculate taxes"
)
}
taxLines = shippingMethod.tax_lines
} else {
const orderLines = await this.taxProviderService_ const orderLines = await this.taxProviderService_
.withTransaction(this.manager_) .withTransaction(this.manager_)
.getTaxLines(cartOrOrder.items, calculationContext) .getTaxLines(cartOrOrder.items, calculationContext)
taxLines = orderLines.filter((ol) => { totals.tax_lines = orderLines.filter((ol) => {
if ("shipping_method_id" in ol) { if ("shipping_method_id" in ol) {
return ol.shipping_method_id === shippingMethod.id return ol.shipping_method_id === shippingMethod.id
} }
return false return false
}) as ShippingMethodTaxLine[] }) as ShippingMethodTaxLine[]
if (totals.tax_lines.length === 0 && isOrder(cartOrOrder)) {
throw new MedusaError(
MedusaError.Types.UNEXPECTED_STATE,
"Tax Lines must be joined on shipping method to calculate taxes"
)
} }
totals.tax_lines = taxLines
} }
if (totals.tax_lines.length > 0) { if (totals.tax_lines.length > 0) {
totals.original_tax_total = const includesTax =
await this.taxCalculationStrategy_.calculate( this.featureFlagRouter_.isFeatureEnabled(
TaxInclusivePricingFeatureFlag.key
) && shippingMethod.includes_tax
totals.original_tax_total = await this.taxCalculationStrategy_.calculate(
[], [],
totals.tax_lines, totals.tax_lines,
calculationContext calculationContext
) )
totals.tax_total = totals.original_tax_total totals.tax_total = totals.original_tax_total
if (includesTax) {
totals.subtotal -= totals.tax_total
} else {
totals.original_total += totals.original_tax_total totals.original_total += totals.original_tax_total
totals.total += totals.tax_total totals.total += totals.tax_total
} }
} }
if (cartOrOrder.discounts) {
if (cartOrOrder.discounts.some((d) => d.rule.type === "free_shipping")) {
totals.total = 0
totals.tax_total = 0
} }
const hasFreeShipping = cartOrOrder.discounts?.some(
(d) => d.rule.type === DiscountRuleType.FREE_SHIPPING
)
if (hasFreeShipping) {
totals.total = 0
totals.subtotal = 0
totals.tax_total = 0
} }
return totals return totals
@@ -260,21 +278,33 @@ class TotalsService extends TransactionBaseService {
* @param opts - options * @param opts - options
* @return the calculated subtotal * @return the calculated subtotal
*/ */
getSubtotal(cartOrOrder: Cart | Order, opts: SubtotalOptions = {}): number { async getSubtotal(
cartOrOrder: Cart | Order,
opts: SubtotalOptions = {}
): Promise<number> {
let subtotal = 0 let subtotal = 0
if (!cartOrOrder.items) { if (!cartOrOrder.items) {
return subtotal return subtotal
} }
cartOrOrder.items.map((item) => { const getLineItemSubtotal = async (item: LineItem): Promise<number> => {
const totals = await this.getLineItemTotals(item, cartOrOrder, {
include_tax: true,
exclude_gift_cards: true,
})
return totals.subtotal
}
for (const item of cartOrOrder.items) {
if (opts.excludeNonDiscounts) { if (opts.excludeNonDiscounts) {
if (item.allow_discounts) { if (item.allow_discounts) {
subtotal += item.unit_price * item.quantity subtotal += await getLineItemSubtotal(item)
} }
} else { continue
subtotal += item.unit_price * item.quantity }
subtotal += await getLineItemSubtotal(item)
} }
})
return this.rounded(subtotal) return this.rounded(subtotal)
} }
@@ -284,11 +314,23 @@ class TotalsService extends TransactionBaseService {
* @param cartOrOrder - cart or order to calculate subtotal for * @param cartOrOrder - cart or order to calculate subtotal for
* @return shipping total * @return shipping total
*/ */
getShippingTotal(cartOrOrder: Cart | Order): number { async getShippingTotal(cartOrOrder: Cart | Order): Promise<number> {
const { shipping_methods } = cartOrOrder const { shipping_methods } = cartOrOrder
return shipping_methods.reduce((acc, next) => {
return acc + next.price let total = 0
}, 0) for (const shippingMethod of shipping_methods) {
const totals = await this.getShippingMethodTotals(
shippingMethod,
cartOrOrder,
{
include_tax: true,
}
)
total += totals.subtotal
}
return total
} }
/** /**
@@ -311,8 +353,8 @@ class TotalsService extends TransactionBaseService {
return null return null
} }
const calculationContext = this.getCalculationContext(cartOrOrder) const calculationContext = await this.getCalculationContext(cartOrOrder)
const giftCardTotal = this.getGiftCardTotal(cartOrOrder) const giftCardTotal = await this.getGiftCardTotal(cartOrOrder)
let taxLines: (ShippingMethodTaxLine | LineItemTaxLine)[] let taxLines: (ShippingMethodTaxLine | LineItemTaxLine)[]
if (isOrder(cartOrOrder)) { if (isOrder(cartOrOrder)) {
@@ -335,9 +377,9 @@ class TotalsService extends TransactionBaseService {
taxLines = taxLines.concat(shippingTaxLines) taxLines = taxLines.concat(shippingTaxLines)
} else { } else {
const subtotal = this.getSubtotal(cartOrOrder) const subtotal = await this.getSubtotal(cartOrOrder)
const shippingTotal = this.getShippingTotal(cartOrOrder) const shippingTotal = await this.getShippingTotal(cartOrOrder)
const discountTotal = this.getDiscountTotal(cartOrOrder) const discountTotal = await this.getDiscountTotal(cartOrOrder)
return this.rounded( return this.rounded(
(subtotal - discountTotal - giftCardTotal.total + shippingTotal) * (subtotal - discountTotal - giftCardTotal.total + shippingTotal) *
(cartOrOrder.tax_rate / 100) (cartOrOrder.tax_rate / 100)
@@ -388,17 +430,17 @@ class TotalsService extends TransactionBaseService {
* @param options - controls what should be included in allocation map * @param options - controls what should be included in allocation map
* @return the allocation map for the line items in the cart or order. * @return the allocation map for the line items in the cart or order.
*/ */
getAllocationMap( async getAllocationMap(
orderOrCart: Cart | Order, orderOrCart: Cart | Order,
options: AllocationMapOptions = {} options: AllocationMapOptions = {}
): LineAllocationsMap { ): Promise<LineAllocationsMap> {
const allocationMap: LineAllocationsMap = {} const allocationMap: LineAllocationsMap = {}
if (!options.exclude_discounts) { if (!options.exclude_discounts) {
let lineDiscounts: LineDiscountAmount[] = [] let lineDiscounts: LineDiscountAmount[] = []
const discount = orderOrCart.discounts.find( const discount = orderOrCart.discounts?.find(
({ rule }) => rule.type !== "free_shipping" ({ rule }) => rule.type !== DiscountRuleType.FREE_SHIPPING
) )
if (discount) { if (discount) {
lineDiscounts = this.getLineDiscounts(orderOrCart, discount) lineDiscounts = this.getLineDiscounts(orderOrCart, discount)
@@ -424,8 +466,8 @@ class TotalsService extends TransactionBaseService {
if (!options.exclude_gift_cards) { if (!options.exclude_gift_cards) {
let lineGiftCards: LineDiscountAmount[] = [] let lineGiftCards: LineDiscountAmount[] = []
if (orderOrCart.gift_cards && orderOrCart.gift_cards.length) { if (orderOrCart.gift_cards && orderOrCart.gift_cards.length) {
const subtotal = this.getSubtotal(orderOrCart) const subtotal = await this.getSubtotal(orderOrCart)
const giftCardTotal = this.getGiftCardTotal(orderOrCart) const giftCardTotal = await this.getGiftCardTotal(orderOrCart)
// If the fixed discount exceeds the subtotal we should // If the fixed discount exceeds the subtotal we should
// calculate a 100% discount // calculate a 100% discount
@@ -480,20 +522,38 @@ class TotalsService extends TransactionBaseService {
* @param lineItem - the line item to calculate the refund amount for. * @param lineItem - the line item to calculate the refund amount for.
* @return the line item refund amount. * @return the line item refund amount.
*/ */
getLineItemRefund(order: Order, lineItem: LineItem): number { async getLineItemRefund(order: Order, lineItem: LineItem): Promise<number> {
const allocationMap = this.getAllocationMap(order) const allocationMap = await this.getAllocationMap(order)
const includesTax =
this.featureFlagRouter_.isFeatureEnabled(
TaxInclusivePricingFeatureFlag.key
) && lineItem.includes_tax
const discountAmount = const discountAmount =
(allocationMap[lineItem.id]?.discount?.unit_amount || 0) * (allocationMap[lineItem.id]?.discount?.unit_amount || 0) *
lineItem.quantity lineItem.quantity
const lineSubtotal = let lineSubtotal = lineItem.unit_price * lineItem.quantity - discountAmount
lineItem.unit_price * lineItem.quantity - discountAmount
/* /*
* Used for backcompat with old tax system * Used for backcompat with old tax system
*/ */
if (order.tax_rate !== null) { if (order.tax_rate !== null) {
const taxAmountIncludedInPrice = !includesTax
? 0
: Math.round(
calculatePriceTaxAmount({
price: lineItem.unit_price,
taxRate: order.tax_rate / 100,
includesTax,
})
)
lineSubtotal =
(lineItem.unit_price - taxAmountIncludedInPrice) * lineItem.quantity -
discountAmount
const taxRate = order.tax_rate / 100 const taxRate = order.tax_rate / 100
return this.rounded(lineSubtotal * (1 + taxRate)) return this.rounded(lineSubtotal * (1 + taxRate))
} }
@@ -508,6 +568,23 @@ class TotalsService extends TransactionBaseService {
) )
} }
const taxRate = lineItem.tax_lines.reduce((acc, next) => {
return acc + next.rate / 100
}, 0)
const taxAmountIncludedInPrice = !includesTax
? 0
: Math.round(
calculatePriceTaxAmount({
price: lineItem.unit_price,
taxRate,
includesTax,
})
)
lineSubtotal =
(lineItem.unit_price - taxAmountIncludedInPrice) * lineItem.quantity -
discountAmount
const taxTotal = lineItem.tax_lines.reduce((acc, next) => { const taxTotal = lineItem.tax_lines.reduce((acc, next) => {
const taxRate = next.rate / 100 const taxRate = next.rate / 100
return acc + this.rounded(lineSubtotal * taxRate) return acc + this.rounded(lineSubtotal * taxRate)
@@ -524,7 +601,7 @@ class TotalsService extends TransactionBaseService {
* @param lineItems - the line items to calculate refund total for * @param lineItems - the line items to calculate refund total for
* @return the calculated subtotal * @return the calculated subtotal
*/ */
getRefundTotal(order: Order, lineItems: LineItem[]): number { async getRefundTotal(order: Order, lineItems: LineItem[]): Promise<number> {
let itemIds = order.items.map((i) => i.id) let itemIds = order.items.map((i) => i.id)
// in case we swap a swap, we need to include swap items // in case we swap a swap, we need to include swap items
@@ -542,16 +619,18 @@ class TotalsService extends TransactionBaseService {
} }
} }
const refunds = lineItems.map((i) => { const refunds: number[] = []
if (!itemIds.includes(i.id)) { for (const item of lineItems) {
if (!itemIds.includes(item.id)) {
throw new MedusaError( throw new MedusaError(
MedusaError.Types.INVALID_DATA, MedusaError.Types.INVALID_DATA,
"Line item does not exist on order" "Line item does not exist on order"
) )
} }
return this.getLineItemRefund(order, i) const refund = await this.getLineItemRefund(order, item)
}) refunds.push(refund)
}
return this.rounded(refunds.reduce((acc, next) => acc + next, 0)) return this.rounded(refunds.reduce((acc, next) => acc + next, 0))
} }
@@ -624,7 +703,7 @@ class TotalsService extends TransactionBaseService {
lineItem: LineItem, lineItem: LineItem,
discount: Discount discount: Discount
): number { ): number {
const matchingDiscount = lineItem.adjustments.find( const matchingDiscount = lineItem.adjustments?.find(
(adjustment) => adjustment.discount_id === discount.id (adjustment) => adjustment.discount_id === discount.id
) )
@@ -709,13 +788,24 @@ class TotalsService extends TransactionBaseService {
cartOrOrder: Cart | Order, cartOrOrder: Cart | Order,
options: LineItemTotalsOptions = {} options: LineItemTotalsOptions = {}
): Promise<LineItemTotals> { ): Promise<LineItemTotals> {
const calculationContext = this.getCalculationContext(cartOrOrder, { const calculationContext = await this.getCalculationContext(cartOrOrder, {
exclude_shipping: true, exclude_shipping: true,
exclude_gift_cards: options.exclude_gift_cards,
}) })
const lineItemAllocation = const lineItemAllocation =
calculationContext.allocation_map[lineItem.id] || {} calculationContext.allocation_map[lineItem.id] || {}
const subtotal = lineItem.unit_price * lineItem.quantity let subtotal = lineItem.unit_price * lineItem.quantity
if (
this.featureFlagRouter_.isFeatureEnabled(
TaxInclusivePricingFeatureFlag.key
) &&
lineItem.includes_tax &&
options.include_tax
) {
subtotal = 0 // in that case we need to know the tax rate to compute it later
}
const gift_card_total = lineItemAllocation.gift_card?.amount || 0 const gift_card_total = lineItemAllocation.gift_card?.amount || 0
const discount_total = const discount_total =
(lineItemAllocation.discount?.unit_amount || 0) * lineItem.quantity (lineItemAllocation.discount?.unit_amount || 0) * lineItem.quantity
@@ -735,14 +825,32 @@ class TotalsService extends TransactionBaseService {
// Tax Information // Tax Information
if (options.include_tax) { if (options.include_tax) {
// When we have an order with a null'ed tax rate we know that it is an // When we have an order with a nulled or undefined tax rate we know that it is an
// order from the old tax system. The following is a backward compat // order from the old tax system. The following is a backward compat
// calculation. // calculation.
if (isOrder(cartOrOrder) && cartOrOrder.tax_rate !== null) { if (isOrder(cartOrOrder) && cartOrOrder.tax_rate != null) {
lineItemTotals.original_tax_total = const taxRate = cartOrOrder.tax_rate / 100
subtotal * (cartOrOrder.tax_rate / 100)
const includesTax =
this.featureFlagRouter_.isFeatureEnabled(
TaxInclusivePricingFeatureFlag.key
) && lineItem.includes_tax
const taxIncludedInPrice = !lineItem.includes_tax
? 0
: Math.round(
calculatePriceTaxAmount({
price: lineItem.unit_price,
taxRate: taxRate,
includesTax,
})
)
lineItemTotals.subtotal =
(lineItem.unit_price - taxIncludedInPrice) * lineItem.quantity
lineItemTotals.total = lineItemTotals.subtotal
lineItemTotals.original_tax_total = lineItemTotals.subtotal * taxRate
lineItemTotals.tax_total = lineItemTotals.tax_total =
(subtotal - discount_total) * (cartOrOrder.tax_rate / 100) (lineItemTotals.subtotal - discount_total) * taxRate
lineItemTotals.total += lineItemTotals.tax_total lineItemTotals.total += lineItemTotals.tax_total
lineItemTotals.original_total += lineItemTotals.original_tax_total lineItemTotals.original_total += lineItemTotals.original_tax_total
@@ -795,15 +903,27 @@ class TotalsService extends TransactionBaseService {
lineItemTotals.tax_lines, lineItemTotals.tax_lines,
calculationContext calculationContext
) )
lineItemTotals.total += lineItemTotals.tax_total
calculationContext.allocation_map = {} // Don't account for discounts calculationContext.allocation_map = {} // Don't account for discounts
lineItemTotals.original_tax_total = lineItemTotals.original_tax_total = await this.taxCalculationStrategy_.calculate(
await this.taxCalculationStrategy_.calculate(
[lineItem], [lineItem],
lineItemTotals.tax_lines, lineItemTotals.tax_lines,
calculationContext calculationContext
) )
if (
this.featureFlagRouter_.isFeatureEnabled(
TaxInclusivePricingFeatureFlag.key
) &&
lineItem.includes_tax
) {
lineItemTotals.subtotal +=
lineItem.unit_price * lineItem.quantity -
lineItemTotals.original_tax_total
lineItemTotals.total += lineItemTotals.subtotal
lineItemTotals.original_total += lineItemTotals.subtotal
}
lineItemTotals.total += lineItemTotals.tax_total
lineItemTotals.original_total += lineItemTotals.original_tax_total lineItemTotals.original_total += lineItemTotals.original_tax_total
} }
@@ -851,7 +971,9 @@ class TotalsService extends TransactionBaseService {
*/ */
async getGiftCardableAmount(cartOrOrder: Cart | Order): Promise<number> { async getGiftCardableAmount(cartOrOrder: Cart | Order): Promise<number> {
if (cartOrOrder.region?.gift_cards_taxable) { if (cartOrOrder.region?.gift_cards_taxable) {
return this.getSubtotal(cartOrOrder) - this.getDiscountTotal(cartOrOrder) const subtotal = await this.getSubtotal(cartOrOrder)
const discountTotal = await this.getDiscountTotal(cartOrOrder)
return subtotal - discountTotal
} }
return await this.getTotal(cartOrOrder, { return await this.getTotal(cartOrOrder, {
@@ -864,12 +986,15 @@ class TotalsService extends TransactionBaseService {
* @param cartOrOrder - the cart or order to get gift card amount for * @param cartOrOrder - the cart or order to get gift card amount for
* @return the gift card amount applied to the cart or order * @return the gift card amount applied to the cart or order
*/ */
getGiftCardTotal(cartOrOrder: Cart | Order): { async getGiftCardTotal(
cartOrOrder: Cart | Order
): Promise<{
total: number total: number
tax_total: number tax_total: number
} { }> {
const giftCardable = const subtotal = await this.getSubtotal(cartOrOrder)
this.getSubtotal(cartOrOrder) - this.getDiscountTotal(cartOrOrder) const discountTotal = await this.getDiscountTotal(cartOrOrder)
const giftCardable = subtotal - discountTotal
if ("gift_card_transactions" in cartOrOrder) { if ("gift_card_transactions" in cartOrOrder) {
// gift_card_transactions only exist on orders so we can // gift_card_transactions only exist on orders so we can
@@ -936,19 +1061,15 @@ class TotalsService extends TransactionBaseService {
* @param cartOrOrder - the cart or order to calculate discounts for * @param cartOrOrder - the cart or order to calculate discounts for
* @return the total discounts amount * @return the total discounts amount
*/ */
getDiscountTotal(cartOrOrder: Cart | Order): number { async getDiscountTotal(cartOrOrder: Cart | Order): Promise<number> {
const subtotal = this.getSubtotal(cartOrOrder, { const subtotal = await this.getSubtotal(cartOrOrder, {
excludeNonDiscounts: true, excludeNonDiscounts: true,
}) })
if (!cartOrOrder.discounts || !cartOrOrder.discounts.length) {
return 0
}
// we only support having free shipping and one other discount, so first // we only support having free shipping and one other discount, so first
// find the discount, which is not free shipping. // find the discount, which is not free shipping.
const discount = cartOrOrder.discounts.find( const discount = cartOrOrder.discounts?.find(
({ rule }) => rule.type !== "free_shipping" ({ rule }) => rule.type !== DiscountRuleType.FREE_SHIPPING
) )
if (!discount) { if (!discount) {
@@ -970,11 +1091,11 @@ class TotalsService extends TransactionBaseService {
* @param options - options to gather context by * @param options - options to gather context by
* @return the tax calculation context * @return the tax calculation context
*/ */
getCalculationContext( async getCalculationContext(
cartOrOrder: Cart | Order, cartOrOrder: Cart | Order,
options: CalculationContextOptions = {} options: CalculationContextOptions = {}
): TaxCalculationContext { ): Promise<TaxCalculationContext> {
const allocationMap = this.getAllocationMap(cartOrOrder, { const allocationMap = await this.getAllocationMap(cartOrOrder, {
exclude_gift_cards: options.exclude_gift_cards, exclude_gift_cards: options.exclude_gift_cards,
exclude_discounts: options.exclude_discounts, exclude_discounts: options.exclude_discounts,
}) })
File diff suppressed because it is too large. Load diff
@@ -1,9 +1,11 @@
import TaxCalculationStrategy from "../tax-calculation" import TaxCalculationStrategy from "../tax-calculation"
import TaxInclusivePricingFeatureFlag from "../../loaders/feature-flags/tax-inclusive-pricing"
import { FlagRouter } from "../../utils/flag-router"
const toTest = [ const toTest = [
[
"calculates correctly without gift card",
{ {
title: "calculates correctly without gift card",
/* /*
* Subtotal = 2 * 100 = 200 * Subtotal = 2 * 100 = 200
* Taxable amount = 200 - 10 = 190 * Taxable amount = 200 - 10 = 190
@@ -54,9 +56,10 @@ const toTest = [
}, },
}, },
}, },
],
[
"calculates correctly with gift card",
{ {
title: "calculates correctly with gift card",
/* /*
* Subtotal = 2 * 100 = 200 * Subtotal = 2 * 100 = 200
* Taxable amount = 200 - 10 = 180 * Taxable amount = 200 - 10 = 180
@@ -107,15 +110,173 @@ const toTest = [
}, },
}, },
}, },
],
[
"calculates correctly with tax inclusive pricing",
{
/*
* Subtotal = 3 * 100 = 100
* Taxable amount = 300
* Taxline 1 = 100 * 0.2 * 2 = 40
* Taxline 2 = 100 * 0.2 * 1 = 20
* Total tax = 60
*/
expected: 60,
flags: { [TaxInclusivePricingFeatureFlag.key]: true },
items: [
{
id: "item_1",
unit_price: 120,
quantity: 2,
includes_tax: true,
},
{
id: "item_2",
unit_price: 100,
quantity: 1,
includes_tax: false,
},
],
taxLines: [
{
item_id: "item_1",
name: "Name 1",
rate: 20,
},
{
item_id: "item_2",
name: "Name 2",
rate: 20,
},
],
context: {
shipping_address: null,
customer: {
email: "test@testson.com",
},
region: {
gift_cards_taxable: true,
},
shipping_methods: [],
allocation_map: {},
},
},
],
[
"calculates correctly with tax inclusive shipping",
{
expected: 40,
flags: { [TaxInclusivePricingFeatureFlag.key]: true },
items: [
{
id: "item_1",
unit_price: 120,
quantity: 1,
includes_tax: true,
},
],
taxLines: [
{
shipping_method_id: "shipping_method_1",
name: "Name 1",
rate: 15,
},
{
shipping_method_id: "shipping_method_2",
name: "Name 2",
rate: 5,
},
{
item_id: "item_1",
name: "Name 1",
rate: 20,
},
],
context: {
shipping_address: null,
customer: {
email: "test@testson.com",
},
region: {
gift_cards_taxable: true,
},
shipping_methods: [
{ id: "shipping_method_1", price: 115, includes_tax: true },
{ id: "shipping_method_2", price: 105, includes_tax: true },
],
allocation_map: {},
},
},
],
[
"calculates correctly with tax inclusive pricing and shipping",
{
expected: 85,
flags: { [TaxInclusivePricingFeatureFlag.key]: true },
items: [
{
id: "item_1",
unit_price: 120,
quantity: 2,
includes_tax: true,
},
{
id: "item_2",
unit_price: 100,
quantity: 1,
includes_tax: false,
},
],
taxLines: [
{
shipping_method_id: "shipping_method_1",
name: "Name 1",
rate: 15,
},
{
shipping_method_id: "shipping_method_2",
name: "Name 2",
rate: 10,
},
{
item_id: "item_1",
name: "Name 1",
rate: 20,
},
{
item_id: "item_2",
name: "Name 2",
rate: 20,
},
],
context: {
shipping_address: null,
customer: {
email: "test@testson.com",
},
region: {
gift_cards_taxable: true,
},
shipping_methods: [
{ id: "shipping_method_1", price: 115, includes_tax: true },
{ id: "shipping_method_2", price: 100, includes_tax: false },
],
allocation_map: {},
},
},
],
] ]
describe("TaxCalculationStrategy", () => { describe("TaxCalculationStrategy", () => {
describe("calculate", () => { describe("calculate", () => {
const calcStrat = new TaxCalculationStrategy()
test.each(toTest)( test.each(toTest)(
"$title", "%s",
async ({ items, taxLines, context, expected }) => { async (title, { items, taxLines, context, expected, flags }) => {
const featureFlagRouter = new FlagRouter(flags ?? {})
const calcStrat = new TaxCalculationStrategy({
featureFlagRouter,
})
const val = await calcStrat.calculate(items, taxLines, context) const val = await calcStrat.calculate(items, taxLines, context)
expect(val).toEqual(expected) expect(val).toEqual(expected)
} }
@@ -1,3 +1,4 @@
import { EntityManager } from "typeorm"
import { import {
AbstractPriceSelectionStrategy, AbstractPriceSelectionStrategy,
IPriceSelectionStrategy, IPriceSelectionStrategy,
@@ -5,18 +6,22 @@ import {
PriceSelectionResult, PriceSelectionResult,
PriceType, PriceType,
} from "../interfaces/price-selection-strategy" } from "../interfaces/price-selection-strategy"
import TaxInclusivePricingFeatureFlag from "../loaders/feature-flags/tax-inclusive-pricing"
import { MoneyAmountRepository } from "../repositories/money-amount" import { MoneyAmountRepository } from "../repositories/money-amount"
import { EntityManager } from "typeorm" import { TaxServiceRate } from "../types/tax-service"
import { isDefined } from "../utils" import { FlagRouter } from "../utils/flag-router"
import { isDefined } from "../utils/is-defined"
class PriceSelectionStrategy extends AbstractPriceSelectionStrategy { class PriceSelectionStrategy extends AbstractPriceSelectionStrategy {
private moneyAmountRepository_: typeof MoneyAmountRepository private moneyAmountRepository_: typeof MoneyAmountRepository
private featureFlagRouter_: FlagRouter
private manager_: EntityManager private manager_: EntityManager
constructor({ manager, moneyAmountRepository }) { constructor({ manager, featureFlagRouter, moneyAmountRepository }) {
super() super()
this.manager_ = manager this.manager_ = manager
this.moneyAmountRepository_ = moneyAmountRepository this.moneyAmountRepository_ = moneyAmountRepository
this.featureFlagRouter_ = featureFlagRouter
} }
withTransaction(manager: EntityManager): IPriceSelectionStrategy { withTransaction(manager: EntityManager): IPriceSelectionStrategy {
@@ -27,12 +32,116 @@ class PriceSelectionStrategy extends AbstractPriceSelectionStrategy {
return new PriceSelectionStrategy({ return new PriceSelectionStrategy({
manager: manager, manager: manager,
moneyAmountRepository: this.moneyAmountRepository_, moneyAmountRepository: this.moneyAmountRepository_,
featureFlagRouter: this.featureFlagRouter_,
}) })
} }
async calculateVariantPrice( async calculateVariantPrice(
variant_id: string, variant_id: string,
context: PriceSelectionContext context: PriceSelectionContext
): Promise<PriceSelectionResult> {
if (
this.featureFlagRouter_.isFeatureEnabled(
TaxInclusivePricingFeatureFlag.key
)
) {
return this.calculateVariantPrice_new(variant_id, context)
}
return this.calculateVariantPrice_old(variant_id, context)
}
private async calculateVariantPrice_new(
variant_id: string,
context: PriceSelectionContext
): Promise<PriceSelectionResult> {
const moneyRepo = this.manager_.getCustomRepository(
this.moneyAmountRepository_
)
const [prices, count] = await moneyRepo.findManyForVariantInRegion(
variant_id,
context.region_id,
context.currency_code,
context.customer_id,
context.include_discount_prices,
true
)
const result: PriceSelectionResult = {
originalPrice: null,
calculatedPrice: null,
prices,
originalPriceIncludesTax: null,
calculatedPriceIncludesTax: null,
}
if (!count || !context) {
return result
}
const taxRate = context.tax_rates?.reduce(
(accRate: number, nextTaxRate: TaxServiceRate) => {
return accRate + (nextTaxRate.rate || 0) / 100
},
0
)
for (const ma of prices) {
let isTaxInclusive = ma.currency?.includes_tax || false
if (ma.price_list?.includes_tax) {
// PriceList specific price so use the PriceList tax setting
isTaxInclusive = ma.price_list.includes_tax
} else if (ma.region?.includes_tax) {
// Region specific price so use the Region tax setting
isTaxInclusive = ma.region.includes_tax
}
delete ma.currency
delete ma.region
if (
context.region_id &&
ma.region_id === context.region_id &&
ma.price_list_id === null &&
ma.min_quantity === null &&
ma.max_quantity === null
) {
result.originalPriceIncludesTax = isTaxInclusive
result.originalPrice = ma.amount
}
if (
context.currency_code &&
ma.currency_code === context.currency_code &&
ma.price_list_id === null &&
ma.min_quantity === null &&
ma.max_quantity === null &&
result.originalPrice === null // region prices take precedence
) {
result.originalPriceIncludesTax = isTaxInclusive
result.originalPrice = ma.amount
}
if (
isValidQuantity(ma, context.quantity) &&
isValidAmount(ma.amount, result, isTaxInclusive, taxRate) &&
((context.currency_code &&
ma.currency_code === context.currency_code) ||
(context.region_id && ma.region_id === context.region_id))
) {
result.calculatedPrice = ma.amount
result.calculatedPriceType = ma.price_list?.type || PriceType.DEFAULT
result.calculatedPriceIncludesTax = isTaxInclusive
}
}
return result
}
private async calculateVariantPrice_old(
variant_id: string,
context: PriceSelectionContext
): Promise<PriceSelectionResult> { ): Promise<PriceSelectionResult> {
const moneyRepo = this.manager_.getCustomRepository( const moneyRepo = this.manager_.getCustomRepository(
this.moneyAmountRepository_ this.moneyAmountRepository_
@@ -65,6 +174,9 @@ class PriceSelectionStrategy extends AbstractPriceSelectionStrategy {
} }
for (const ma of prices) { for (const ma of prices) {
delete ma.currency
delete ma.region
if ( if (
context.region_id && context.region_id &&
ma.region_id === context.region_id && ma.region_id === context.region_id &&
@@ -103,6 +215,31 @@ class PriceSelectionStrategy extends AbstractPriceSelectionStrategy {
} }
} }
const isValidAmount = (
amount: number,
result: PriceSelectionResult,
isTaxInclusive: boolean,
taxRate?: number
): boolean => {
if (result.calculatedPrice === null) {
return true
}
if (isTaxInclusive === result.calculatedPriceIncludesTax) {
// if both or neither are tax inclusive compare equally
return amount < result.calculatedPrice
}
if (typeof taxRate !== "undefined") {
return isTaxInclusive
? amount < (1 + taxRate) * result.calculatedPrice
: (1 + taxRate) * amount < result.calculatedPrice
}
// if we dont have a taxrate we can't compare mixed prices
return false
}
const isValidQuantity = (price, quantity): boolean => const isValidQuantity = (price, quantity): boolean =>
(isDefined(quantity) && isValidPriceWithQuantity(price, quantity)) || (isDefined(quantity) && isValidPriceWithQuantity(price, quantity)) ||
(typeof quantity === "undefined" && isValidPriceWithoutQuantity(price)) (typeof quantity === "undefined" && isValidPriceWithoutQuantity(price))
@@ -1,11 +1,21 @@
import { LineItem } from "../models/line-item" import {
import { ShippingMethod } from "../models/shipping-method" LineItem,
import { LineItemTaxLine } from "../models/line-item-tax-line" LineItemTaxLine,
import { ShippingMethodTaxLine } from "../models/shipping-method-tax-line" ShippingMethod,
import { TaxCalculationContext } from "../interfaces/tax-service" ShippingMethodTaxLine,
import { ITaxCalculationStrategy } from "../interfaces/tax-calculation-strategy" } from "../models"
import { ITaxCalculationStrategy, TaxCalculationContext } from "../interfaces"
import { calculatePriceTaxAmount } from "../utils"
import { FlagRouter } from "../utils/flag-router"
import TaxInclusivePricingFeatureFlag from "../loaders/feature-flags/tax-inclusive-pricing"
class TaxCalculationStrategy implements ITaxCalculationStrategy { class TaxCalculationStrategy implements ITaxCalculationStrategy {
protected readonly featureFlagRouter_: FlagRouter
constructor({ featureFlagRouter }) {
this.featureFlagRouter_ = featureFlagRouter
}
async calculate( async calculate(
items: LineItem[], items: LineItem[],
taxLines: (ShippingMethodTaxLine | LineItemTaxLine)[], taxLines: (ShippingMethodTaxLine | LineItemTaxLine)[],
@@ -33,18 +43,47 @@ class TaxCalculationStrategy implements ITaxCalculationStrategy {
context: TaxCalculationContext context: TaxCalculationContext
): number { ): number {
let taxTotal = 0 let taxTotal = 0
for (const i of items) {
const allocations = context.allocation_map[i.id] || {}
let taxableAmount = i.quantity * i.unit_price for (const item of items) {
const allocations = context.allocation_map[item.id] || {}
const filteredTaxLines = taxLines.filter((tl) => tl.item_id === item.id)
const includesTax =
this.featureFlagRouter_.isFeatureEnabled(
TaxInclusivePricingFeatureFlag.key
) && item.includes_tax
let taxableAmount
if (includesTax) {
const taxRate = filteredTaxLines.reduce(
(accRate: number, nextLineItemTaxLine: LineItemTaxLine) => {
return accRate + (nextLineItemTaxLine.rate || 0) / 100
},
0
)
const taxIncludedInPrice = Math.round(
calculatePriceTaxAmount({
price: item.unit_price,
taxRate,
includesTax,
})
)
taxableAmount = (item.unit_price - taxIncludedInPrice) * item.quantity
} else {
taxableAmount = item.unit_price * item.quantity
}
taxableAmount -= taxableAmount -=
((allocations.discount && allocations.discount.unit_amount) || 0) * ((allocations.discount && allocations.discount.unit_amount) || 0) *
i.quantity item.quantity
const lineRates = taxLines.filter((tl) => tl.item_id === i.id) for (const filteredTaxLine of filteredTaxLines) {
for (const lineRate of lineRates) { taxTotal += Math.round(
taxTotal += Math.round(taxableAmount * (lineRate.rate / 100)) calculatePriceTaxAmount({
price: taxableAmount,
taxRate: filteredTaxLine.rate / 100,
})
)
} }
} }
return taxTotal return taxTotal
@@ -54,12 +93,19 @@ class TaxCalculationStrategy implements ITaxCalculationStrategy {
shipping_methods: ShippingMethod[], shipping_methods: ShippingMethod[],
taxLines: ShippingMethodTaxLine[] taxLines: ShippingMethodTaxLine[]
): number { ): number {
const taxInclusiveEnabled = this.featureFlagRouter_.isFeatureEnabled(
TaxInclusivePricingFeatureFlag.key
)
let taxTotal = 0 let taxTotal = 0
for (const sm of shipping_methods) { for (const sm of shipping_methods) {
const amount = sm.price
const lineRates = taxLines.filter((tl) => tl.shipping_method_id === sm.id) const lineRates = taxLines.filter((tl) => tl.shipping_method_id === sm.id)
for (const lineRate of lineRates) { for (const lineRate of lineRates) {
taxTotal += Math.round(amount * (lineRate.rate / 100)) taxTotal += calculatePriceTaxAmount({
price: sm.price,
taxRate: lineRate.rate / 100,
includesTax: taxInclusiveEnabled && sm.includes_tax,
})
} }
} }
return taxTotal return taxTotal
+3
View File
@@ -0,0 +1,3 @@
export type UpdateCurrencyInput = {
includes_tax?: boolean
}
+8 -1
View File
@@ -126,12 +126,19 @@ export type CreatePriceListInput = {
customer_groups?: { id: string }[] customer_groups?: { id: string }[]
starts_at?: Date starts_at?: Date
ends_at?: Date ends_at?: Date
includes_tax?: boolean
} }
export type UpdatePriceListInput = Partial< export type UpdatePriceListInput = Partial<
Pick< Pick<
PriceList, PriceList,
"name" | "description" | "starts_at" | "ends_at" | "status" | "type" | "name"
| "description"
| "starts_at"
| "ends_at"
| "status"
| "type"
| "includes_tax"
> >
> & { > & {
prices?: AdminPriceListPricesUpdateReq[] prices?: AdminPriceListPricesUpdateReq[]
+5 -2
View File
@@ -1,11 +1,13 @@
import { MoneyAmount, ProductVariant, Product, ShippingOption } from "../models"
import { TaxServiceRate } from "./tax-service"
import { PriceSelectionContext } from "../interfaces/price-selection-strategy" import { PriceSelectionContext } from "../interfaces/price-selection-strategy"
import { MoneyAmount, Product, ProductVariant, ShippingOption } from "../models"
import { TaxServiceRate } from "./tax-service"
export type ProductVariantPricing = { export type ProductVariantPricing = {
prices: MoneyAmount[] prices: MoneyAmount[]
original_price: number | null original_price: number | null
calculated_price: number | null calculated_price: number | null
original_price_includes_tax?: boolean | null
calculated_price_includes_tax?: boolean | null
calculated_price_type?: string | null calculated_price_type?: string | null
} & TaxedPricing } & TaxedPricing
@@ -26,6 +28,7 @@ export type PricingContext = {
export type ShippingOptionPricing = { export type ShippingOptionPricing = {
price_incl_tax: number | null price_incl_tax: number | null
tax_rates: TaxServiceRate[] | null tax_rates: TaxServiceRate[] | null
tax_amount: number
} }
export type PricedShippingOption = Partial<ShippingOption> & export type PricedShippingOption = Partial<ShippingOption> &
+2 -3
View File
@@ -1,6 +1,3 @@
import { FindConfig } from "./common"
import { Region } from "../models"
export type UpdateRegionInput = { export type UpdateRegionInput = {
name?: string name?: string
currency_code?: string currency_code?: string
@@ -12,6 +9,7 @@ export type UpdateRegionInput = {
payment_providers?: string[] payment_providers?: string[]
fulfillment_providers?: string[] fulfillment_providers?: string[]
countries?: string[] countries?: string[]
includes_tax?: boolean
metadata?: Record<string, unknown> metadata?: Record<string, unknown>
} }
@@ -23,5 +21,6 @@ export type CreateRegionInput = {
payment_providers: string[] payment_providers: string[]
fulfillment_providers: string[] fulfillment_providers: string[]
countries: string[] countries: string[]
includes_tax?: boolean
metadata?: Record<string, unknown> metadata?: Record<string, unknown>
} }
@@ -44,6 +44,7 @@ export type CreateShippingOptionInput = {
profile_id: string profile_id: string
provider_id: string provider_id: string
data: Record<string, unknown> data: Record<string, unknown>
includes_tax?: boolean
amount?: number amount?: number
is_return?: boolean is_return?: boolean
@@ -71,4 +72,5 @@ export type UpdateShippingOptionInput = {
provider_id?: string provider_id?: string
profile_id?: string profile_id?: string
data?: string data?: string
includes_tax?: boolean
} }
Loaded 100 of 105 files, more files were not shown because too many files have changed in this diff. Show more