diff --git a/integration-tests/api/__tests__/taxes/shipping-options.js b/integration-tests/api/__tests__/taxes/shipping-options.js new file mode 100644 index 0000000000..7f91216db8 --- /dev/null +++ b/integration-tests/api/__tests__/taxes/shipping-options.js @@ -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, + }), + ]) + }) +}) diff --git a/packages/medusa/src/api/routes/admin/shipping-options/list-shipping-options.ts b/packages/medusa/src/api/routes/admin/shipping-options/list-shipping-options.ts index 1632acf626..f6790d8732 100644 --- a/packages/medusa/src/api/routes/admin/shipping-options/list-shipping-options.ts +++ b/packages/medusa/src/api/routes/admin/shipping-options/list-shipping-options.ts @@ -1,6 +1,7 @@ import { Transform } from "class-transformer" import { IsBoolean, IsOptional, IsString } from "class-validator" import { defaultFields, defaultRelations } from "." +import { PricingService } from "../../../../services" import { validator } from "../../../../utils/validator" import { optionalBooleanMapper } from "../../../../utils/validators/is-boolean" @@ -50,12 +51,15 @@ export default async (req, res) => { ) const optionService = req.scope.resolve("shippingOptionService") + const pricingService: PricingService = req.scope.resolve("pricingService") const [data, count] = await optionService.listAndCount(validatedParams, { select: defaultFields, 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 { diff --git a/packages/medusa/src/api/routes/store/shipping-options/list-options.ts b/packages/medusa/src/api/routes/store/shipping-options/list-options.ts index d18cbc8d1a..1d2f7ae04b 100644 --- a/packages/medusa/src/api/routes/store/shipping-options/list-options.ts +++ b/packages/medusa/src/api/routes/store/shipping-options/list-options.ts @@ -1,5 +1,5 @@ import { IsBooleanString, IsOptional, IsString } from "class-validator" -import ProductService from "../../../../services/product" +import { PricingService, ProductService } from "../../../../services" import ShippingOptionService from "../../../../services/shipping-option" import { validator } from "../../../../utils/validator" @@ -33,6 +33,7 @@ export default async (req, res) => { (validated.product_ids && validated.product_ids.split(",")) || [] const regionId = validated.region_id const productService: ProductService = req.scope.resolve("productService") + const pricingService: PricingService = req.scope.resolve("pricingService") const shippingOptionService: ShippingOptionService = req.scope.resolve( "shippingOptionService" ) @@ -59,7 +60,9 @@ export default async (req, res) => { 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 { diff --git a/packages/medusa/src/api/routes/store/shipping-options/list-shipping-options.ts b/packages/medusa/src/api/routes/store/shipping-options/list-shipping-options.ts index 20c44ff660..7b339be8cf 100644 --- a/packages/medusa/src/api/routes/store/shipping-options/list-shipping-options.ts +++ b/packages/medusa/src/api/routes/store/shipping-options/list-shipping-options.ts @@ -1,4 +1,4 @@ -import CartService from "../../../../services/cart" +import { CartService, PricingService } from "../../../../services" import ShippingProfileService from "../../../../services/shipping-profile" /** @@ -26,6 +26,7 @@ export default async (req, res) => { const { cart_id } = req.params const cartService: CartService = req.scope.resolve("cartService") + const pricingService: PricingService = req.scope.resolve("pricingService") const shippingProfileService: ShippingProfileService = req.scope.resolve( "shippingProfileService" ) @@ -36,6 +37,9 @@ export default async (req, res) => { }) 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 }) } diff --git a/packages/medusa/src/repositories/tax-rate.ts b/packages/medusa/src/repositories/tax-rate.ts index 72a897d759..4536a93661 100644 --- a/packages/medusa/src/repositories/tax-rate.ts +++ b/packages/medusa/src/repositories/tax-rate.ts @@ -254,21 +254,11 @@ export class TaxRateRepository extends Repository { return unionBy(...results, (txr) => txr.id) } - async listByShippingOption(optionId: string, config: TaxRateListByConfig) { + async listByShippingOption(optionId: string) { let rates = this.createQueryBuilder("txr") .leftJoin(ShippingTaxRate, "ptr", "ptr.rate_id = txr.id") - .leftJoin( - ShippingMethod, - "sm", - "sm.shipping_option_id = ptr.shipping_option_id" - ) - .where("sm.shipping_option_id = :optionId", { optionId }) + .where("ptr.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() } } diff --git a/packages/medusa/src/services/__mocks__/pricing.js b/packages/medusa/src/services/__mocks__/pricing.js index 7730379e57..b16e0e3812 100644 --- a/packages/medusa/src/services/__mocks__/pricing.js +++ b/packages/medusa/src/services/__mocks__/pricing.js @@ -8,6 +8,9 @@ export const PricingServiceMock = { setVariantPrices: jest.fn().mockImplementation((variant) => { return Promise.resolve(variant) }), + setShippingOptionPrices: jest.fn().mockImplementation((opts) => { + return Promise.resolve(opts) + }), } const mock = jest.fn().mockImplementation(() => { diff --git a/packages/medusa/src/services/pricing.ts b/packages/medusa/src/services/pricing.ts index 5754a658ed..8c93527b87 100644 --- a/packages/medusa/src/services/pricing.ts +++ b/packages/medusa/src/services/pricing.ts @@ -1,12 +1,14 @@ import { EntityManager } from "typeorm" +import { MedusaError } from "medusa-core-utils" import { ProductVariantService, RegionService, TaxProviderService } from "." -import { Product, ProductVariant } from "../models" +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" @@ -384,6 +386,108 @@ class PricingService extends TransactionBaseService { }) ) } + + /** + * 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 { + 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 = {} + ): Promise { + const regions = new Set() + + 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 diff --git a/packages/medusa/src/services/shipping-profile.js b/packages/medusa/src/services/shipping-profile.js index 1161d7aafb..ca84aa63df 100644 --- a/packages/medusa/src/services/shipping-profile.js +++ b/packages/medusa/src/services/shipping-profile.js @@ -420,7 +420,7 @@ class ShippingProfileService extends BaseService { * Finds all the shipping profiles that cover the products in a cart, and * validates all options that are available for the cart. * @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) { const profileIds = this.getProfilesInCart_(cart) diff --git a/packages/medusa/src/services/tax-provider.ts b/packages/medusa/src/services/tax-provider.ts index 23460fc058..c8a50d7fc0 100644 --- a/packages/medusa/src/services/tax-provider.ts +++ b/packages/medusa/src/services/tax-provider.ts @@ -340,8 +340,7 @@ class TaxProviderService extends BaseService { let toReturn: TaxServiceRate[] = [] const optionRates = await this.taxRateService_.listByShippingOption( - optionId, - { region_id: regionDetails.id } + optionId ) if (optionRates.length > 0) { diff --git a/packages/medusa/src/services/tax-rate.ts b/packages/medusa/src/services/tax-rate.ts index 70a593ac58..c6bc2e8d97 100644 --- a/packages/medusa/src/services/tax-rate.ts +++ b/packages/medusa/src/services/tax-rate.ts @@ -329,13 +329,10 @@ class TaxRateService extends BaseService { }) } - async listByShippingOption( - shippingOptionId: string, - config: TaxRateListByConfig - ): Promise { + async listByShippingOption(shippingOptionId: string): Promise { return await this.atomicPhase_(async (manager: EntityManager) => { const taxRateRepo = manager.getCustomRepository(this.taxRateRepository_) - return await taxRateRepo.listByShippingOption(shippingOptionId, config) + return await taxRateRepo.listByShippingOption(shippingOptionId) }) } } diff --git a/packages/medusa/src/types/pricing.ts b/packages/medusa/src/types/pricing.ts index b0c1a5d3bb..77118975e6 100644 --- a/packages/medusa/src/types/pricing.ts +++ b/packages/medusa/src/types/pricing.ts @@ -1,4 +1,4 @@ -import { MoneyAmount, ProductVariant, Product } from "../models" +import { MoneyAmount, ProductVariant, Product, ShippingOption } from "../models" import { TaxServiceRate } from "./tax-service" import { PriceSelectionContext } from "../interfaces/price-selection-strategy" @@ -23,6 +23,14 @@ export type PricingContext = { tax_rate: number | null } +export type ShippingOptionPricing = { + price_incl_tax: number | null + tax_rates: TaxServiceRate[] | null +} + +export type PricedShippingOption = Partial & + ShippingOptionPricing + export type PricedVariant = Partial & ProductVariantPricing export type PricedProduct = Omit, "variants"> & {