feat(core-flows): calculate SO price on cart ops (#10563)

**What**
- calculate the shipping option price when creating a shipping method
- calculate the shipping option price when refreshing cart
- add testing for calculated SO flow
- fix validation on calculated SO creation
- add manual fulfillment provider for testing
- add `from_location` to calculation context

---

RESOLVES CMRC-778
RESOLVES CMRC-602
RESOLVES SUP-136
This commit is contained in:
Frane Polić
2024-12-16 22:28:30 +00:00
committed by GitHub
parent 95baacfd00
commit 0c49470066
21 changed files with 903 additions and 52 deletions
@@ -31,12 +31,18 @@ medusaIntegrationTestRunner({
)
expect(response.status).toEqual(200)
expect(response.data.fulfillment_providers).toEqual([
expect.objectContaining({
id: "manual_test-provider",
is_enabled: true,
}),
])
expect(response.data.fulfillment_providers).toEqual(
expect.arrayContaining([
expect.objectContaining({
id: "manual_test-provider",
is_enabled: true,
}),
expect.objectContaining({
id: "manual-calculated_test-provider-calculated",
is_enabled: true,
}),
])
)
})
it("should list all fulfillment providers scoped by stock location", async () => {
@@ -0,0 +1,404 @@
import { medusaIntegrationTestRunner } from "@medusajs/test-utils"
import {
createAdminUser,
generatePublishableKey,
generateStoreHeaders,
} from "../../../../helpers/create-admin-user"
jest.setTimeout(50000)
const env = { MEDUSA_FF_MEDUSA_V2: true }
const adminHeaders = { headers: { "x-medusa-access-token": "test_token" } }
medusaIntegrationTestRunner({
env,
testSuite: ({ dbConnection, getContainer, api }) => {
describe("Store: Shipping Option API", () => {
let appContainer
let salesChannel
let region
let regionTwo
let product
let stockLocation
let shippingProfile
let fulfillmentSet
let cart
let shippingOptionCalculated
let shippingOptionFlat
let storeHeaders
beforeAll(async () => {
appContainer = getContainer()
})
beforeEach(async () => {
const publishableKey = await generatePublishableKey(appContainer)
storeHeaders = generateStoreHeaders({ publishableKey })
await createAdminUser(dbConnection, adminHeaders, appContainer)
region = (
await api.post(
"/admin/regions",
{ name: "US", currency_code: "usd", countries: ["US"] },
adminHeaders
)
).data.region
regionTwo = (
await api.post(
"/admin/regions",
{
name: "Test region two",
currency_code: "dkk",
countries: ["DK"],
is_tax_inclusive: true,
},
adminHeaders
)
).data.region
salesChannel = (
await api.post(
"/admin/sales-channels",
{ name: "first channel", description: "channel" },
adminHeaders
)
).data.sales_channel
product = (
await api.post(
"/admin/products",
{
title: "Test fixture",
options: [
{ title: "size", values: ["large", "small"] },
{ title: "color", values: ["green"] },
],
variants: [
{
title: "Test variant",
manage_inventory: false,
prices: [
{
currency_code: "usd",
amount: 100,
},
{
currency_code: "dkk",
amount: 100,
},
],
options: {
size: "large",
color: "green",
},
},
],
},
adminHeaders
)
).data.product
stockLocation = (
await api.post(
`/admin/stock-locations`,
{ name: "test location" },
adminHeaders
)
).data.stock_location
await api.post(
`/admin/stock-locations/${stockLocation.id}/sales-channels`,
{ add: [salesChannel.id] },
adminHeaders
)
shippingProfile = (
await api.post(
`/admin/shipping-profiles`,
{ name: "Test", type: "default" },
adminHeaders
)
).data.shipping_profile
const fulfillmentSets = (
await api.post(
`/admin/stock-locations/${stockLocation.id}/fulfillment-sets?fields=*fulfillment_sets`,
{
name: "Test",
type: "test-type",
},
adminHeaders
)
).data.stock_location.fulfillment_sets
fulfillmentSet = (
await api.post(
`/admin/fulfillment-sets/${fulfillmentSets[0].id}/service-zones`,
{
name: "Test",
geo_zones: [
{ type: "country", country_code: "us" },
{ type: "country", country_code: "dk" },
],
},
adminHeaders
)
).data.fulfillment_set
await api.post(
`/admin/stock-locations/${stockLocation.id}/fulfillment-providers`,
{
add: [
"manual_test-provider",
"manual-calculated_test-provider-calculated",
],
},
adminHeaders
)
shippingOptionCalculated = (
await api.post(
`/admin/shipping-options`,
{
name: "Calculated shipping option",
service_zone_id: fulfillmentSet.service_zones[0].id,
shipping_profile_id: shippingProfile.id,
provider_id: "manual-calculated_test-provider-calculated",
price_type: "calculated",
type: {
label: "Test type",
description: "Test description",
code: "test-code",
},
prices: [], // TODO: Update endpoint validator to not require prices if type is calculated
rules: [],
},
adminHeaders
)
).data.shipping_option
shippingOptionFlat = (
await api.post(
`/admin/shipping-options`,
{
name: "Flat rate shipping option",
service_zone_id: fulfillmentSet.service_zones[0].id,
shipping_profile_id: shippingProfile.id,
provider_id: "manual_test-provider",
price_type: "flat",
type: {
label: "Test type",
description: "Test description",
code: "test-code",
},
prices: [
{
currency_code: "usd",
amount: 1000,
},
{
region_id: region.id,
amount: 1100,
},
{
region_id: region.id,
amount: 0,
rules: [
{
operator: "gt",
attribute: "item_total",
value: 2000,
},
],
},
{
region_id: regionTwo.id,
amount: 500,
},
],
rules: [],
},
adminHeaders
)
).data.shipping_option
})
describe("GET /store/shipping-options?cart_id=", () => {
it("should get calculated and flat rate shipping options for a cart successfully", async () => {
cart = (
await api.post(
`/store/carts`,
{
region_id: region.id,
sales_channel_id: salesChannel.id,
currency_code: "usd",
email: "test@admin.com",
items: [
{
variant_id: product.variants[0].id,
quantity: 2,
},
],
},
storeHeaders
)
).data.cart
const resp = await api.get(
`/store/shipping-options?cart_id=${cart.id}`,
storeHeaders
)
const shippingOptions = resp.data.shipping_options
expect(shippingOptions).toHaveLength(2)
expect(shippingOptions).toEqual(
expect.arrayContaining([
expect.objectContaining({
id: shippingOptionFlat.id,
name: "Flat rate shipping option",
price_type: "flat",
amount: 1100,
is_tax_inclusive: false,
provider_id: "manual_test-provider",
calculated_price: expect.objectContaining({
calculated_amount: 1100,
is_calculated_price_tax_inclusive: false,
}),
}),
expect.objectContaining({
id: shippingOptionCalculated.id,
name: "Calculated shipping option",
price_type: "calculated",
provider_id: "manual-calculated_test-provider-calculated",
calculated_price: null,
prices: [],
// amount doesn't exist for calculated shipping options -> /calculate needs to be called
}),
])
)
})
it("should get fetch pricing for calculated shipping options", async () => {
cart = (
await api.post(
`/store/carts`,
{
region_id: region.id,
sales_channel_id: salesChannel.id,
currency_code: "usd",
email: "test@admin.com",
items: [
{
variant_id: product.variants[0].id,
quantity: 2,
},
],
},
storeHeaders
)
).data.cart
const resp = await api.post(
`/store/shipping-options/${shippingOptionCalculated.id}/calculate?fields=+provider_id`,
{ cart_id: cart.id, data: { pin_id: "test" } },
storeHeaders
)
const shippingOption = resp.data.shipping_option
expect(shippingOption).toEqual(
expect.objectContaining({
id: shippingOptionCalculated.id,
name: "Calculated shipping option",
price_type: "calculated",
provider_id: "manual-calculated_test-provider-calculated",
calculated_price: expect.objectContaining({
calculated_amount: 3,
is_calculated_price_tax_inclusive: false,
}),
amount: 3,
})
)
})
it("should add shipping method with calculated price to cart", async () => {
cart = (
await api.post(
`/store/carts`,
{
region_id: region.id,
sales_channel_id: salesChannel.id,
currency_code: "usd",
email: "test@admin.com",
items: [
{
variant_id: product.variants[0].id,
quantity: 2,
},
],
},
storeHeaders
)
).data.cart
// Select shipping option and create shipping method
let response = await api.post(
`/store/carts/${cart.id}/shipping-methods?fields=*shipping_methods`,
{
option_id: shippingOptionCalculated.id,
data: { pin_id: "test" },
},
storeHeaders
)
expect(response.status).toEqual(200)
expect(response.data.cart).toEqual(
expect.objectContaining({
id: cart.id,
shipping_methods: expect.arrayContaining([
expect.objectContaining({
shipping_option_id: shippingOptionCalculated.id,
amount: 3,
is_tax_inclusive: false,
data: { pin_id: "test" },
}),
]),
shipping_total: 3,
})
)
// Update cart and refresh shipping methods
response = await api.post(
`/store/carts/${cart.id}/line-items?fields=*shipping_methods`,
{
variant_id: product.variants[0].id,
quantity: 1,
},
storeHeaders
)
expect(response.status).toEqual(200)
expect(response.data.cart).toEqual(
expect.objectContaining({
id: cart.id,
shipping_methods: expect.arrayContaining([
expect.objectContaining({
shipping_option_id: shippingOptionCalculated.id,
amount: 4.5,
is_tax_inclusive: false,
data: { pin_id: "test" },
}),
]),
shipping_subtotal: 4.5,
})
)
})
})
})
},
})
+10 -1
View File
@@ -15,6 +15,12 @@ const customFulfillmentProvider = {
id: "test-provider",
}
const customFulfillmentProviderCalculated = {
resolve: require("./dist/utils/providers/fulfillment-manual-calculated")
.default,
id: "test-provider-calculated",
}
module.exports = defineConfig({
admin: {
disable: true,
@@ -28,7 +34,10 @@ module.exports = defineConfig({
[Modules.FULFILLMENT]: {
/** @type {import('@medusajs/fulfillment').FulfillmentModuleOptions} */
options: {
providers: [customFulfillmentProvider],
providers: [
customFulfillmentProvider,
customFulfillmentProviderCalculated,
],
},
},
[Modules.NOTIFICATION]: {
+1 -1
View File
@@ -7,7 +7,7 @@
"scripts": {
"test:integration": "NODE_OPTIONS=--experimental-vm-modules jest --no-cache --maxWorkers=50% --bail --detectOpenHandles --forceExit --logHeapUsage",
"test:integration:chunk": "NODE_OPTIONS=--experimental-vm-modules jest --silent --no-cache --bail --maxWorkers=50% --forceExit --testPathPattern=$(echo $CHUNKS | jq -r \".[${CHUNK}] | .[]\")",
"build": "tsc ./src/* --allowJs --outDir ./dist"
"build": "tsc --allowJs --outDir ./dist"
},
"dependencies": {
"@medusajs/api-key": "workspace:^",
@@ -0,0 +1,8 @@
import { ModuleProvider, Modules } from "@medusajs/framework/utils"
import { ManualFulfillmentService } from "./services/manual-fulfillment"
const services = [ManualFulfillmentService]
export default ModuleProvider(Modules.FULFILLMENT, {
services,
})
@@ -0,0 +1,54 @@
import { AbstractFulfillmentProviderService } from "@medusajs/framework/utils"
export class ManualFulfillmentService extends AbstractFulfillmentProviderService {
static identifier = "manual-calculated"
constructor() {
super()
}
async getFulfillmentOptions() {
return [
{
id: "manual-fulfillment-calculated",
},
{
id: "manual-fulfillment-return-calculated",
is_return: true,
},
]
}
async validateFulfillmentData(optionData, data, context) {
return data
}
async calculatePrice(optionData, data, context) {
return {
calculated_amount:
context.items.reduce((acc, i) => acc + i.quantity, 0) * 1.5, // mock caluclation as 1.5 per item
is_calculated_price_tax_inclusive: false,
}
}
async canCalculate() {
return true
}
async validateOption(data) {
return true
}
async createFulfillment() {
// No data is being sent anywhere
return {}
}
async cancelFulfillment() {
return {}
}
async createReturnFulfillment() {
return {}
}
}
+12
View File
@@ -0,0 +1,12 @@
{
"extends": "../../_tsconfig.base.json",
"include": ["src", "./medusa/**/*"],
"exclude": [
"dist",
"__tests__",
"helpers",
"./**/helpers",
"./**/__snapshots__",
"node_modules"
]
}