fix: add shipping taxes (#1759)
**What** Adds taxes to the shipping prices when listing in admin. Allows store operators to see correct prices when processing returns.
This commit is contained in:
@@ -0,0 +1,109 @@
|
|||||||
|
const path = require("path")
|
||||||
|
|
||||||
|
const setupServer = require("../../../helpers/setup-server")
|
||||||
|
const { useApi } = require("../../../helpers/use-api")
|
||||||
|
const { useDb, initDb } = require("../../../helpers/use-db")
|
||||||
|
const {
|
||||||
|
simpleRegionFactory,
|
||||||
|
simpleProductFactory,
|
||||||
|
simpleShippingTaxRateFactory,
|
||||||
|
simpleShippingOptionFactory,
|
||||||
|
} = require("../../factories")
|
||||||
|
|
||||||
|
const adminSeeder = require("../../helpers/admin-seeder")
|
||||||
|
|
||||||
|
jest.setTimeout(30000)
|
||||||
|
|
||||||
|
describe("Shipping Options Totals Calculations", () => {
|
||||||
|
let medusaProcess
|
||||||
|
let dbConnection
|
||||||
|
|
||||||
|
beforeAll(async () => {
|
||||||
|
const cwd = path.resolve(path.join(__dirname, "..", ".."))
|
||||||
|
dbConnection = await initDb({ cwd })
|
||||||
|
medusaProcess = await setupServer({ cwd })
|
||||||
|
})
|
||||||
|
|
||||||
|
afterAll(async () => {
|
||||||
|
const db = useDb()
|
||||||
|
await db.shutdown()
|
||||||
|
|
||||||
|
medusaProcess.kill()
|
||||||
|
})
|
||||||
|
|
||||||
|
beforeEach(async () => {
|
||||||
|
try {
|
||||||
|
await adminSeeder(dbConnection)
|
||||||
|
} catch (err) {
|
||||||
|
console.log(err)
|
||||||
|
throw err
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
afterEach(async () => {
|
||||||
|
const db = useDb()
|
||||||
|
await db.teardown()
|
||||||
|
})
|
||||||
|
|
||||||
|
it("admin gets correct shipping prices", async () => {
|
||||||
|
const api = useApi()
|
||||||
|
|
||||||
|
const region = await simpleRegionFactory(dbConnection, {
|
||||||
|
tax_rate: 25,
|
||||||
|
})
|
||||||
|
const so = await simpleShippingOptionFactory(dbConnection, {
|
||||||
|
region_id: region.id,
|
||||||
|
price: 100,
|
||||||
|
})
|
||||||
|
await simpleShippingTaxRateFactory(dbConnection, {
|
||||||
|
shipping_option_id: so.id,
|
||||||
|
rate: {
|
||||||
|
region_id: region.id,
|
||||||
|
rate: 10,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
const res = await api.get(`/admin/shipping-options`, {
|
||||||
|
headers: {
|
||||||
|
Authorization: `Bearer test_token`,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(res.data.shipping_options).toEqual([
|
||||||
|
expect.objectContaining({
|
||||||
|
id: so.id,
|
||||||
|
amount: 100,
|
||||||
|
price_incl_tax: 110,
|
||||||
|
}),
|
||||||
|
])
|
||||||
|
})
|
||||||
|
|
||||||
|
it("gets correct shipping prices", async () => {
|
||||||
|
const api = useApi()
|
||||||
|
|
||||||
|
const region = await simpleRegionFactory(dbConnection, {
|
||||||
|
tax_rate: 25,
|
||||||
|
})
|
||||||
|
const so = await simpleShippingOptionFactory(dbConnection, {
|
||||||
|
region_id: region.id,
|
||||||
|
price: 100,
|
||||||
|
})
|
||||||
|
await simpleShippingTaxRateFactory(dbConnection, {
|
||||||
|
shipping_option_id: so.id,
|
||||||
|
rate: {
|
||||||
|
region_id: region.id,
|
||||||
|
rate: 10,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
const res = await api.get(`/store/shipping-options?region_id=${region.id}`)
|
||||||
|
|
||||||
|
expect(res.data.shipping_options).toEqual([
|
||||||
|
expect.objectContaining({
|
||||||
|
id: so.id,
|
||||||
|
amount: 100,
|
||||||
|
price_incl_tax: 110,
|
||||||
|
}),
|
||||||
|
])
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
import { Transform } from "class-transformer"
|
import { Transform } from "class-transformer"
|
||||||
import { IsBoolean, IsOptional, IsString } from "class-validator"
|
import { IsBoolean, IsOptional, IsString } from "class-validator"
|
||||||
import { defaultFields, defaultRelations } from "."
|
import { defaultFields, defaultRelations } from "."
|
||||||
|
import { PricingService } from "../../../../services"
|
||||||
import { validator } from "../../../../utils/validator"
|
import { validator } from "../../../../utils/validator"
|
||||||
import { optionalBooleanMapper } from "../../../../utils/validators/is-boolean"
|
import { optionalBooleanMapper } from "../../../../utils/validators/is-boolean"
|
||||||
|
|
||||||
@@ -50,12 +51,15 @@ export default async (req, res) => {
|
|||||||
)
|
)
|
||||||
|
|
||||||
const optionService = req.scope.resolve("shippingOptionService")
|
const optionService = req.scope.resolve("shippingOptionService")
|
||||||
|
const pricingService: PricingService = req.scope.resolve("pricingService")
|
||||||
const [data, count] = await optionService.listAndCount(validatedParams, {
|
const [data, count] = await optionService.listAndCount(validatedParams, {
|
||||||
select: defaultFields,
|
select: defaultFields,
|
||||||
relations: defaultRelations,
|
relations: defaultRelations,
|
||||||
})
|
})
|
||||||
|
|
||||||
res.status(200).json({ shipping_options: data, count })
|
const options = await pricingService.setShippingOptionPrices(data)
|
||||||
|
|
||||||
|
res.status(200).json({ shipping_options: options, count })
|
||||||
}
|
}
|
||||||
|
|
||||||
export class AdminGetShippingOptionsParams {
|
export class AdminGetShippingOptionsParams {
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { IsBooleanString, IsOptional, IsString } from "class-validator"
|
import { IsBooleanString, IsOptional, IsString } from "class-validator"
|
||||||
import ProductService from "../../../../services/product"
|
import { PricingService, ProductService } from "../../../../services"
|
||||||
import ShippingOptionService from "../../../../services/shipping-option"
|
import ShippingOptionService from "../../../../services/shipping-option"
|
||||||
import { validator } from "../../../../utils/validator"
|
import { validator } from "../../../../utils/validator"
|
||||||
|
|
||||||
@@ -33,6 +33,7 @@ export default async (req, res) => {
|
|||||||
(validated.product_ids && validated.product_ids.split(",")) || []
|
(validated.product_ids && validated.product_ids.split(",")) || []
|
||||||
const regionId = validated.region_id
|
const regionId = validated.region_id
|
||||||
const productService: ProductService = req.scope.resolve("productService")
|
const productService: ProductService = req.scope.resolve("productService")
|
||||||
|
const pricingService: PricingService = req.scope.resolve("pricingService")
|
||||||
const shippingOptionService: ShippingOptionService = req.scope.resolve(
|
const shippingOptionService: ShippingOptionService = req.scope.resolve(
|
||||||
"shippingOptionService"
|
"shippingOptionService"
|
||||||
)
|
)
|
||||||
@@ -59,7 +60,9 @@ export default async (req, res) => {
|
|||||||
relations: ["requirements"],
|
relations: ["requirements"],
|
||||||
})
|
})
|
||||||
|
|
||||||
res.status(200).json({ shipping_options: options })
|
const data = await pricingService.setShippingOptionPrices(options)
|
||||||
|
|
||||||
|
res.status(200).json({ shipping_options: data })
|
||||||
}
|
}
|
||||||
|
|
||||||
export class StoreGetShippingOptionsParams {
|
export class StoreGetShippingOptionsParams {
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import CartService from "../../../../services/cart"
|
import { CartService, PricingService } from "../../../../services"
|
||||||
import ShippingProfileService from "../../../../services/shipping-profile"
|
import ShippingProfileService from "../../../../services/shipping-profile"
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -26,6 +26,7 @@ export default async (req, res) => {
|
|||||||
const { cart_id } = req.params
|
const { cart_id } = req.params
|
||||||
|
|
||||||
const cartService: CartService = req.scope.resolve("cartService")
|
const cartService: CartService = req.scope.resolve("cartService")
|
||||||
|
const pricingService: PricingService = req.scope.resolve("pricingService")
|
||||||
const shippingProfileService: ShippingProfileService = req.scope.resolve(
|
const shippingProfileService: ShippingProfileService = req.scope.resolve(
|
||||||
"shippingProfileService"
|
"shippingProfileService"
|
||||||
)
|
)
|
||||||
@@ -36,6 +37,9 @@ export default async (req, res) => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
const options = await shippingProfileService.fetchCartOptions(cart)
|
const options = await shippingProfileService.fetchCartOptions(cart)
|
||||||
|
const data = await pricingService.setShippingOptionPrices(options, {
|
||||||
|
cart_id,
|
||||||
|
})
|
||||||
|
|
||||||
res.status(200).json({ shipping_options: options })
|
res.status(200).json({ shipping_options: data })
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -254,21 +254,11 @@ export class TaxRateRepository extends Repository<TaxRate> {
|
|||||||
return unionBy(...results, (txr) => txr.id)
|
return unionBy(...results, (txr) => txr.id)
|
||||||
}
|
}
|
||||||
|
|
||||||
async listByShippingOption(optionId: string, config: TaxRateListByConfig) {
|
async listByShippingOption(optionId: string) {
|
||||||
let rates = this.createQueryBuilder("txr")
|
let rates = this.createQueryBuilder("txr")
|
||||||
.leftJoin(ShippingTaxRate, "ptr", "ptr.rate_id = txr.id")
|
.leftJoin(ShippingTaxRate, "ptr", "ptr.rate_id = txr.id")
|
||||||
.leftJoin(
|
.where("ptr.shipping_option_id = :optionId", { optionId })
|
||||||
ShippingMethod,
|
|
||||||
"sm",
|
|
||||||
"sm.shipping_option_id = ptr.shipping_option_id"
|
|
||||||
)
|
|
||||||
.where("sm.shipping_option_id = :optionId", { optionId })
|
|
||||||
|
|
||||||
if (typeof config.region_id !== "undefined") {
|
|
||||||
rates.andWhere("txr.region_id = :regionId", {
|
|
||||||
regionId: config.region_id,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
return await rates.getMany()
|
return await rates.getMany()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,6 +8,9 @@ export const PricingServiceMock = {
|
|||||||
setVariantPrices: jest.fn().mockImplementation((variant) => {
|
setVariantPrices: jest.fn().mockImplementation((variant) => {
|
||||||
return Promise.resolve(variant)
|
return Promise.resolve(variant)
|
||||||
}),
|
}),
|
||||||
|
setShippingOptionPrices: jest.fn().mockImplementation((opts) => {
|
||||||
|
return Promise.resolve(opts)
|
||||||
|
}),
|
||||||
}
|
}
|
||||||
|
|
||||||
const mock = jest.fn().mockImplementation(() => {
|
const mock = jest.fn().mockImplementation(() => {
|
||||||
|
|||||||
@@ -1,12 +1,14 @@
|
|||||||
import { EntityManager } from "typeorm"
|
import { EntityManager } from "typeorm"
|
||||||
|
import { MedusaError } from "medusa-core-utils"
|
||||||
import { ProductVariantService, RegionService, TaxProviderService } from "."
|
import { ProductVariantService, RegionService, TaxProviderService } from "."
|
||||||
import { Product, ProductVariant } from "../models"
|
import { Product, ProductVariant, ShippingOption } from "../models"
|
||||||
import { TaxServiceRate } from "../types/tax-service"
|
import { TaxServiceRate } from "../types/tax-service"
|
||||||
import {
|
import {
|
||||||
ProductVariantPricing,
|
ProductVariantPricing,
|
||||||
TaxedPricing,
|
TaxedPricing,
|
||||||
PricingContext,
|
PricingContext,
|
||||||
PricedProduct,
|
PricedProduct,
|
||||||
|
PricedShippingOption,
|
||||||
PricedVariant,
|
PricedVariant,
|
||||||
} from "../types/pricing"
|
} from "../types/pricing"
|
||||||
import { TransactionBaseService } from "../interfaces"
|
import { TransactionBaseService } from "../interfaces"
|
||||||
@@ -384,6 +386,108 @@ class PricingService extends TransactionBaseService<PricingService> {
|
|||||||
})
|
})
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Gets the prices for a shipping option.
|
||||||
|
* @param shippingOption - the shipping option to get prices for
|
||||||
|
* @param context - the price selection context to use
|
||||||
|
* @return The shipping option prices
|
||||||
|
*/
|
||||||
|
async getShippingOptionPricing(
|
||||||
|
shippingOption: ShippingOption,
|
||||||
|
context: PriceSelectionContext | PricingContext
|
||||||
|
): Promise<PricedShippingOption> {
|
||||||
|
let pricingContext: PricingContext
|
||||||
|
if ("automatic_taxes" in context) {
|
||||||
|
pricingContext = context
|
||||||
|
} else {
|
||||||
|
pricingContext = await this.collectPricingContext(context)
|
||||||
|
}
|
||||||
|
|
||||||
|
let shippingOptionRates: TaxServiceRate[] = []
|
||||||
|
if (
|
||||||
|
pricingContext.automatic_taxes &&
|
||||||
|
pricingContext.price_selection.region_id
|
||||||
|
) {
|
||||||
|
shippingOptionRates =
|
||||||
|
await this.taxProviderService.getRegionRatesForShipping(
|
||||||
|
shippingOption.id,
|
||||||
|
{
|
||||||
|
id: pricingContext.price_selection.region_id,
|
||||||
|
tax_rate: pricingContext.tax_rate,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const price = shippingOption.amount || 0
|
||||||
|
const rate = shippingOptionRates.reduce(
|
||||||
|
(accRate: number, nextTaxRate: TaxServiceRate) => {
|
||||||
|
return accRate + (nextTaxRate.rate || 0) / 100
|
||||||
|
},
|
||||||
|
0
|
||||||
|
)
|
||||||
|
const tax = Math.round(price * rate)
|
||||||
|
const total = price + tax
|
||||||
|
|
||||||
|
return {
|
||||||
|
...shippingOption,
|
||||||
|
price_incl_tax: total,
|
||||||
|
tax_rates: shippingOptionRates,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Set additional prices on a list of shipping options.
|
||||||
|
* @param shippingOptions - list of shipping options on which to set additional prices
|
||||||
|
* @param context - the price selection context to use
|
||||||
|
* @return A list of shipping options with prices
|
||||||
|
*/
|
||||||
|
async setShippingOptionPrices(
|
||||||
|
shippingOptions: ShippingOption[],
|
||||||
|
context: Omit<PriceSelectionContext, "region_id"> = {}
|
||||||
|
): Promise<PricedShippingOption[]> {
|
||||||
|
const regions = new Set<string>()
|
||||||
|
|
||||||
|
for (const shippingOption of shippingOptions) {
|
||||||
|
regions.add(shippingOption.region_id)
|
||||||
|
}
|
||||||
|
|
||||||
|
const contexts = await Promise.all(
|
||||||
|
[...regions].map(async (regionId) => {
|
||||||
|
return {
|
||||||
|
context: await this.collectPricingContext({
|
||||||
|
...context,
|
||||||
|
region_id: regionId,
|
||||||
|
}),
|
||||||
|
region_id: regionId,
|
||||||
|
}
|
||||||
|
})
|
||||||
|
)
|
||||||
|
|
||||||
|
return await Promise.all(
|
||||||
|
shippingOptions.map(async (shippingOption) => {
|
||||||
|
const pricingContext = contexts.find(
|
||||||
|
(c) => c.region_id === shippingOption.region_id
|
||||||
|
)
|
||||||
|
|
||||||
|
if (!pricingContext) {
|
||||||
|
throw new MedusaError(
|
||||||
|
MedusaError.Types.UNEXPECTED_STATE,
|
||||||
|
"Could not find pricing context for shipping option"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const shippingOptionPricing = await this.getShippingOptionPricing(
|
||||||
|
shippingOption,
|
||||||
|
pricingContext.context
|
||||||
|
)
|
||||||
|
return {
|
||||||
|
...shippingOption,
|
||||||
|
...shippingOptionPricing,
|
||||||
|
}
|
||||||
|
})
|
||||||
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export default PricingService
|
export default PricingService
|
||||||
|
|||||||
@@ -420,7 +420,7 @@ class ShippingProfileService extends BaseService {
|
|||||||
* Finds all the shipping profiles that cover the products in a cart, and
|
* Finds all the shipping profiles that cover the products in a cart, and
|
||||||
* validates all options that are available for the cart.
|
* validates all options that are available for the cart.
|
||||||
* @param {Cart} cart - the cart object to find shipping options for
|
* @param {Cart} cart - the cart object to find shipping options for
|
||||||
* @return {[ShippingOption]} a list of the available shipping options
|
* @return {Promise<[ShippingOption]>} a list of the available shipping options
|
||||||
*/
|
*/
|
||||||
async fetchCartOptions(cart) {
|
async fetchCartOptions(cart) {
|
||||||
const profileIds = this.getProfilesInCart_(cart)
|
const profileIds = this.getProfilesInCart_(cart)
|
||||||
|
|||||||
@@ -340,8 +340,7 @@ class TaxProviderService extends BaseService {
|
|||||||
|
|
||||||
let toReturn: TaxServiceRate[] = []
|
let toReturn: TaxServiceRate[] = []
|
||||||
const optionRates = await this.taxRateService_.listByShippingOption(
|
const optionRates = await this.taxRateService_.listByShippingOption(
|
||||||
optionId,
|
optionId
|
||||||
{ region_id: regionDetails.id }
|
|
||||||
)
|
)
|
||||||
|
|
||||||
if (optionRates.length > 0) {
|
if (optionRates.length > 0) {
|
||||||
|
|||||||
@@ -329,13 +329,10 @@ class TaxRateService extends BaseService {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
async listByShippingOption(
|
async listByShippingOption(shippingOptionId: string): Promise<TaxRate[]> {
|
||||||
shippingOptionId: string,
|
|
||||||
config: TaxRateListByConfig
|
|
||||||
): Promise<TaxRate[]> {
|
|
||||||
return await this.atomicPhase_(async (manager: EntityManager) => {
|
return await this.atomicPhase_(async (manager: EntityManager) => {
|
||||||
const taxRateRepo = manager.getCustomRepository(this.taxRateRepository_)
|
const taxRateRepo = manager.getCustomRepository(this.taxRateRepository_)
|
||||||
return await taxRateRepo.listByShippingOption(shippingOptionId, config)
|
return await taxRateRepo.listByShippingOption(shippingOptionId)
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { MoneyAmount, ProductVariant, Product } from "../models"
|
import { MoneyAmount, ProductVariant, Product, ShippingOption } from "../models"
|
||||||
import { TaxServiceRate } from "./tax-service"
|
import { TaxServiceRate } from "./tax-service"
|
||||||
import { PriceSelectionContext } from "../interfaces/price-selection-strategy"
|
import { PriceSelectionContext } from "../interfaces/price-selection-strategy"
|
||||||
|
|
||||||
@@ -23,6 +23,14 @@ export type PricingContext = {
|
|||||||
tax_rate: number | null
|
tax_rate: number | null
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export type ShippingOptionPricing = {
|
||||||
|
price_incl_tax: number | null
|
||||||
|
tax_rates: TaxServiceRate[] | null
|
||||||
|
}
|
||||||
|
|
||||||
|
export type PricedShippingOption = Partial<ShippingOption> &
|
||||||
|
ShippingOptionPricing
|
||||||
|
|
||||||
export type PricedVariant = Partial<ProductVariant> & ProductVariantPricing
|
export type PricedVariant = Partial<ProductVariant> & ProductVariantPricing
|
||||||
|
|
||||||
export type PricedProduct = Omit<Partial<Product>, "variants"> & {
|
export type PricedProduct = Omit<Partial<Product>, "variants"> & {
|
||||||
|
|||||||
Reference in New Issue
Block a user