refactor(medusa): Convert ShippingOptionService to typescript (#1644)

* convert shipping option service to typescript

* update jsdoc

* make update look update object

* update price reverting for calculated prices  when deselecting free shipping option

* update typescript service implementation

* pr feedback
This commit is contained in:
Philip Korsholm
2022-06-13 09:55:50 +02:00
committed by GitHub
parent 3359e189a7
commit b478b10438
5 changed files with 774 additions and 3 deletions
@@ -56,7 +56,7 @@ export class ShippingOption extends SoftDeletableEntity {
price_type: ShippingOptionPriceType
@Column({ type: "int", nullable: true })
amount: number
amount: number | null
@Column({ default: false })
is_return: boolean
@@ -156,8 +156,8 @@ describe("ShippingOptionService", () => {
expect(fulfillmentProviderService.canCalculate).toHaveBeenCalledWith({
amount: null,
data: { provider_data: "true" },
price_type: "calculated",
provider_id: "provider",
price_type: "calculated",
})
expect(shippingOptionRepository.save).toHaveBeenCalledTimes(1)
+8 -1
View File
@@ -705,7 +705,14 @@ class CartService extends TransactionBaseService<CartService> {
cart.shipping_methods.map(async (shippingMethod) => {
// if free shipping discount is removed, we adjust the shipping
// back to its original amount
shippingMethod.price = shippingMethod.shipping_option.amount
// if shipping option amount is null, we assume the option is calculated
shippingMethod.price =
shippingMethod.shipping_option.amount ??
(await this.shippingOptionService_.getPrice_(
shippingMethod.shipping_option,
shippingMethod.data,
cart
))
return shippingMethodRepository.save(shippingMethod)
})
)
@@ -0,0 +1,698 @@
import { MedusaError } from "medusa-core-utils"
import { DeepPartial, EntityManager } from "typeorm"
import { TransactionBaseService } from "../interfaces"
import {
Cart,
Order,
ShippingMethod,
ShippingOption,
ShippingOptionPriceType,
ShippingOptionRequirement,
} from "../models"
import { ShippingMethodRepository } from "../repositories/shipping-method"
import { ShippingOptionRepository } from "../repositories/shipping-option"
import { ShippingOptionRequirementRepository } from "../repositories/shipping-option-requirement"
import { ExtendedFindConfig, FindConfig, Selector } from "../types/common"
import {
CreateShippingMethodDto,
ShippingMethodUpdate,
UpdateShippingOptionInput,
CreateShippingOptionInput,
} from "../types/shipping-options"
import { buildQuery, setMetadata } from "../utils"
import FulfillmentProviderService from "./fulfillment-provider"
import RegionService from "./region"
/**
* Provides layer to manipulate profiles.
*/
class ShippingOptionService extends TransactionBaseService<ShippingOptionService> {
protected readonly providerService_: FulfillmentProviderService
protected readonly regionService_: RegionService
protected readonly requirementRepository_: typeof ShippingOptionRequirementRepository
protected readonly optionRepository_: typeof ShippingOptionRepository
protected readonly methodRepository_: typeof ShippingMethodRepository
protected manager_: EntityManager
protected transactionManager_: EntityManager | undefined
constructor({
manager,
shippingOptionRepository,
shippingOptionRequirementRepository,
shippingMethodRepository,
fulfillmentProviderService,
regionService,
}) {
// eslint-disable-next-line prefer-rest-params
super(arguments[0])
this.manager_ = manager
this.optionRepository_ = shippingOptionRepository
this.methodRepository_ = shippingMethodRepository
this.requirementRepository_ = shippingOptionRequirementRepository
this.providerService_ = fulfillmentProviderService
this.regionService_ = regionService
}
/**
* Validates a requirement
* @param {ShippingOptionRequirement} requirement - the requirement to validate
* @param {string} optionId - the id to validate the requirement
* @return {ShippingOptionRequirement} a validated shipping requirement
*/
async validateRequirement_(
requirement: ShippingOptionRequirement,
optionId: string | undefined = undefined
): Promise<ShippingOptionRequirement> {
return await this.atomicPhase_(async (manager) => {
if (!requirement.type) {
throw new MedusaError(
MedusaError.Types.INVALID_DATA,
"A Shipping Requirement must have a type field"
)
}
if (
requirement.type !== "min_subtotal" &&
requirement.type !== "max_subtotal"
) {
throw new MedusaError(
MedusaError.Types.INVALID_DATA,
"Requirement type must be one of min_subtotal, max_subtotal"
)
}
const reqRepo = manager.getCustomRepository(this.requirementRepository_)
const existingReq = await reqRepo.findOne({
where: { id: requirement.id },
})
if (!existingReq && requirement.id) {
throw new MedusaError(
MedusaError.Types.INVALID_DATA,
"ID does not exist"
)
}
// If no option id is provided, we are currently in the process of creating
// a new shipping option. Therefore, simply return the requirement, such
// that the cascading will take care of the creation of the requirement.
if (!optionId) {
return requirement
}
let req
if (existingReq) {
req = await reqRepo.save({
...existingReq,
...requirement,
})
} else {
const created = reqRepo.create({
...requirement,
shipping_option_id: optionId,
})
req = await reqRepo.save(created)
}
return req
})
}
/**
* @param {Object} selector - the query object for find
* @param {object} config - config object
* @return {Promise} the result of the find operation
*/
async list(
selector: Selector<ShippingMethod>,
config: FindConfig<ShippingOption> = { skip: 0, take: 50 }
): Promise<ShippingOption[]> {
return await this.atomicPhase_(async (transactionManager) => {
const optRepo = transactionManager.getCustomRepository(
this.optionRepository_
)
const query = buildQuery(selector, config)
return optRepo.find(query)
})
}
/**
* @param {Object} selector - the query object for find
* @param {object} config - config object
* @return {Promise} the result of the find operation
*/
async listAndCount(
selector: Selector<ShippingMethod>,
config: FindConfig<ShippingOption> = { skip: 0, take: 50 }
): Promise<[ShippingOption[], number]> {
return await this.atomicPhase_(async (transactionManager) => {
const optRepo = transactionManager.getCustomRepository(
this.optionRepository_
)
const query = buildQuery(selector, config)
return await optRepo.findAndCount(query)
})
}
/**
* Gets a profile by id.
* Throws in case of DB Error and if profile was not found.
* @param {string} optionId - the id of the profile to get.
* @param {object} options - the options to get a profile
* @return {Promise<Product>} the profile document.
*/
async retrieve(
optionId,
options: { select?: (keyof ShippingOption)[]; relations?: string[] } = {}
): Promise<ShippingOption> {
return await this.atomicPhase_(async (transactionManager) => {
const soRepo: ShippingOptionRepository =
transactionManager.getCustomRepository(this.optionRepository_)
const query: ExtendedFindConfig<ShippingOption> = {
where: { id: optionId },
}
if (options.select) {
query.select = options.select
}
if (options.relations) {
query.relations = options.relations
}
const option = await soRepo.findOne(query)
if (!option) {
throw new MedusaError(
MedusaError.Types.NOT_FOUND,
`Shipping Option with ${optionId} was not found`
)
}
return option
})
}
/**
* Updates a shipping method's associations. Useful when a cart is completed
* and its methods should be copied to an order/swap entity.
* @param {string} id - the id of the shipping method to update
* @param {object} update - the values to update the method with
* @return {Promise<ShippingMethod>} the resulting shipping method
*/
async updateShippingMethod(
id: string,
update: ShippingMethodUpdate
): Promise<ShippingMethod | undefined> {
return await this.atomicPhase_(async (manager) => {
const methodRepo: ShippingMethodRepository = manager.getCustomRepository(
this.methodRepository_
)
const method = await methodRepo.findOne({ where: { id } })
if (!method) {
return undefined
}
for (const key of Object.keys(update).filter(
(k) => typeof update[k] !== `undefined`
)) {
method[key] = update[key]
}
return methodRepo.save(method)
})
}
/**
* Removes a given shipping method
* @param {ShippingMethod | Array<ShippingMethod>} shippingMethods - the shipping method to remove
* @returns removed shipping methods
*/
async deleteShippingMethods(
shippingMethods: ShippingMethod | ShippingMethod[]
): Promise<ShippingMethod[]> {
const removeEntities: ShippingMethod[] = Array.isArray(shippingMethods)
? shippingMethods
: [shippingMethods]
return await this.atomicPhase_(async (manager) => {
const methodRepo = manager.getCustomRepository(this.methodRepository_)
return await methodRepo.remove(removeEntities)
})
}
/**
* Creates a shipping method for a given cart.
* @param {string} optionId - the id of the option to use for the method.
* @param {object} data - the optional provider data to use.
* @param {object} config - the cart to create the shipping method for.
* @return {ShippingMethod} the resulting shipping method.
*/
async createShippingMethod(
optionId: string,
data: object,
config: CreateShippingMethodDto
): Promise<ShippingMethod> {
return await this.atomicPhase_(async (manager) => {
const option = await this.retrieve(optionId, {
relations: ["requirements"],
})
const methodRepo = manager.getCustomRepository(this.methodRepository_)
if (typeof config.cart !== "undefined") {
this.validateCartOption(option, config.cart)
}
const validatedData = await this.providerService_.validateFulfillmentData(
option,
data,
config.cart || {}
)
let methodPrice
if (typeof config.price === "number") {
methodPrice = config.price
} else {
methodPrice = await this.getPrice_(option, validatedData, config.cart)
}
const toCreate: Partial<ShippingMethod> = {
shipping_option_id: option.id,
data: validatedData,
price: methodPrice,
}
if (config.order) {
toCreate.order_id = config.order.id
}
if (config.cart) {
toCreate.cart_id = config.cart.id
}
if (config.cart_id) {
toCreate.cart_id = config.cart_id
}
if (config.return_id) {
toCreate.return_id = config.return_id
}
if (config.order_id) {
toCreate.order_id = config.order_id
}
if (config.claim_order_id) {
toCreate.claim_order_id = config.claim_order_id
}
const method = await methodRepo.create(toCreate)
const created = await methodRepo.save(method)
return methodRepo.findOne({
where: { id: created.id },
relations: ["shipping_option"],
}) as unknown as ShippingMethod
})
}
/**
* Checks if a given option id is a valid option for a cart. If it is the
* option is returned with the correct price. Throws when region_ids do not
* match, or when the shipping option requirements are not satisfied.
* @param {object} option - the option object to check
* @param {Cart} cart - the cart object to check against
* @return {ShippingOption} the validated shipping option
*/
validateCartOption(
option: ShippingOption,
cart: Cart
): ShippingOption | null {
if (option.is_return) {
return null
}
if (cart.region_id !== option.region_id) {
throw new MedusaError(
MedusaError.Types.INVALID_DATA,
"The shipping option is not available in the cart's region"
)
}
const subtotal = cart.subtotal as number
const requirementResults: boolean[] = option.requirements.map(
(requirement) => {
switch (requirement.type) {
case "max_subtotal":
return requirement.amount > subtotal
case "min_subtotal":
return requirement.amount <= subtotal
default:
return true
}
}
)
if (!requirementResults.every(Boolean)) {
throw new MedusaError(
MedusaError.Types.NOT_ALLOWED,
"The Cart does not satisfy the shipping option's requirements"
)
}
return option
}
/**
* Creates a new shipping option. Used both for outbound and inbound shipping
* options. The difference is registered by the `is_return` field which
* defaults to false.
* @param {ShippingOption} data - the data to create shipping options
* @return {Promise<ShippingOption>} the result of the create operation
*/
async create(data: CreateShippingOptionInput): Promise<ShippingOption> {
return this.atomicPhase_(async (manager) => {
const optionRepo = manager.getCustomRepository(this.optionRepository_)
const option = await optionRepo.create(
data as DeepPartial<ShippingOption>
)
const region = await this.regionService_
.withTransaction(manager)
.retrieve(option.region_id, {
relations: ["fulfillment_providers"],
})
if (
!region.fulfillment_providers.find(
({ id }) => id === option.provider_id
)
) {
throw new MedusaError(
MedusaError.Types.INVALID_DATA,
"The fulfillment provider is not available in the provided region"
)
}
option.price_type = await this.validatePriceType_(data.price_type, option)
option.amount =
data.price_type === "calculated" ? null : data.amount ?? null
const isValid = await this.providerService_.validateOption(option)
if (!isValid) {
throw new MedusaError(
MedusaError.Types.INVALID_DATA,
"The fulfillment provider cannot validate the shipping option"
)
}
if (typeof data.requirements !== "undefined") {
const acc: ShippingOptionRequirement[] = []
for (const r of data.requirements) {
const validated = await this.validateRequirement_(r)
if (acc.find((raw) => raw.type === validated.type)) {
throw new MedusaError(
MedusaError.Types.INVALID_DATA,
"Only one requirement of each type is allowed"
)
}
if (
acc.find(
(raw) =>
(raw.type === "max_subtotal" &&
validated.amount > raw.amount) ||
(raw.type === "min_subtotal" && validated.amount < raw.amount)
)
) {
throw new MedusaError(
MedusaError.Types.INVALID_DATA,
"Max. subtotal must be greater than Min. subtotal"
)
}
acc.push(validated)
}
}
const result = await optionRepo.save(option)
return result
})
}
/**
* Validates a shipping option price
* @param {ShippingOptionPriceType} priceType - the price to validate
* @param {ShippingOption} option - the option to validate against
* @return {Promise<ShippingOptionPriceType>} the validated price
*/
async validatePriceType_(
priceType: ShippingOptionPriceType,
option: ShippingOption
): Promise<ShippingOptionPriceType> {
if (
!priceType ||
(priceType !== "flat_rate" && priceType !== "calculated")
) {
throw new MedusaError(
MedusaError.Types.INVALID_DATA,
"The price must be of type flat_rate or calculated"
)
}
if (priceType === "calculated") {
const canCalculate = await this.providerService_.canCalculate(option)
if (!canCalculate) {
throw new MedusaError(
MedusaError.Types.INVALID_DATA,
"The fulfillment provider cannot calculate prices for this option"
)
}
}
return priceType
}
/**
* Updates a profile. Metadata updates and product updates should use
* dedicated methods, e.g. `setMetadata`, etc. The function
* will throw errors if metadata or product updates are attempted.
* @param {string} optionId - the id of the option. Must be a string that
* can be casted to an ObjectId
* @param {object} update - an object with the update values.
* @return {Promise} resolves to the update result.
*/
async update(
optionId: string,
update: UpdateShippingOptionInput
): Promise<ShippingOption> {
return this.atomicPhase_(async (manager) => {
const option = await this.retrieve(optionId, {
relations: ["requirements"],
})
if (typeof update.metadata !== "undefined") {
option.metadata = await setMetadata(option, update.metadata)
}
if (update.region_id || update.provider_id || update.data) {
throw new MedusaError(
MedusaError.Types.NOT_ALLOWED,
"Region and Provider cannot be updated after creation"
)
}
if (typeof update.is_return !== "undefined") {
throw new MedusaError(
MedusaError.Types.NOT_ALLOWED,
"is_return cannot be changed after creation"
)
}
if (typeof update.requirements !== "undefined") {
const acc: ShippingOptionRequirement[] = []
for (const r of update.requirements) {
const validated = await this.validateRequirement_(r, optionId)
if (acc.find((raw) => raw.type === validated.type)) {
throw new MedusaError(
MedusaError.Types.INVALID_DATA,
"Only one requirement of each type is allowed"
)
}
if (
acc.find(
(raw) =>
(raw.type === "max_subtotal" &&
validated.amount > raw.amount) ||
(raw.type === "min_subtotal" && validated.amount < raw.amount)
)
) {
throw new MedusaError(
MedusaError.Types.INVALID_DATA,
"Max. subtotal must be greater than Min. subtotal"
)
}
acc.push(validated)
}
if (option.requirements) {
const accReqs = acc.map((a) => a.id)
const toRemove = option.requirements.filter(
(r) => !accReqs.includes(r.id)
)
await Promise.all(
toRemove.map(async (req) => {
await this.removeRequirement(req.id)
})
)
}
option.requirements = acc
}
if (typeof update.price_type !== "undefined") {
option.price_type = await this.validatePriceType_(
update.price_type,
option
)
if (update.price_type === "calculated") {
option.amount = null
}
}
if (
typeof update.amount !== "undefined" &&
option.price_type !== "calculated"
) {
option.amount = update.amount
}
if (typeof update.name !== "undefined") {
option.name = update.name
}
if (typeof update.admin_only !== "undefined") {
option.admin_only = update.admin_only
}
const optionRepo = manager.getCustomRepository(this.optionRepository_)
return await optionRepo.save(option)
})
}
/**
* Deletes a profile with a given profile id.
* @param {string} optionId - the id of the profile to delete. Must be
* castable as an ObjectId
* @return {Promise} the result of the delete operation.
*/
async delete(optionId: string): Promise<ShippingOption | void> {
return await this.atomicPhase_(async (manager) => {
try {
const option = await this.retrieve(optionId)
const optionRepo = manager.getCustomRepository(this.optionRepository_)
return optionRepo.softRemove(option)
} catch (error) {
// Delete is idempotent, but we return a promise to allow then-chaining
return
}
})
}
/**
* Adds a requirement to a shipping option. Only 1 requirement of each type
* is allowed.
* @param {string} optionId - the option to add the requirement to.
* @param {ShippingOptionRequirement} requirement - the requirement for the option.
* @return {Promise} the result of update
*/
async addRequirement(
optionId: string,
requirement: ShippingOptionRequirement
): Promise<ShippingOption> {
return this.atomicPhase_(async (manager) => {
const option = await this.retrieve(optionId, {
relations: ["requirements"],
})
const validatedReq = await this.validateRequirement_(requirement)
if (option.requirements.find((r) => r.type === validatedReq.type)) {
throw new MedusaError(
MedusaError.Types.DUPLICATE_ERROR,
`A requirement with type: ${validatedReq.type} already exists`
)
}
option.requirements.push(validatedReq)
const optionRepo = manager.getCustomRepository(this.optionRepository_)
return optionRepo.save(option)
})
}
/**
* Removes a requirement from a shipping option
* @param {string} requirementId - the id of the requirement to remove
* @return {Promise} the result of update
*/
async removeRequirement(
requirementId
): Promise<ShippingOptionRequirement | void> {
return await this.atomicPhase_(async (manager) => {
const reqRepo: ShippingOptionRequirementRepository =
manager.getCustomRepository(this.requirementRepository_)
const requirement = await reqRepo.findOne({
where: { id: requirementId },
})
// Delete is idempotent, but we return a promise to allow then-chaining
if (typeof requirement === "undefined") {
return Promise.resolve()
}
return await reqRepo.softRemove(requirement)
})
}
/**
* Returns the amount to be paid for a shipping method. Will ask the
* fulfillment provider to calculate the price if the shipping option has the
* price type "calculated".
* @param {ShippingOption} option - the shipping option to retrieve the price
* for.
* @param {ShippingData} data - the shipping data to retrieve the price.
* @param {Cart | Order} cart - the context in which the price should be
* retrieved.
* @return {Promise<Number>} the price of the shipping option.
*/
async getPrice_(
option: ShippingOption,
data: object,
cart: Cart | Order | undefined
): Promise<number> {
if (option.price_type === "calculated") {
return this.providerService_.calculatePrice(option, data, cart)
}
return option.amount as number
}
}
export default ShippingOptionService
@@ -0,0 +1,66 @@
import { Cart, Order } from ".."
import { ShippingOptionPriceType } from "../models/shipping-option"
import {
RequirementType,
ShippingOptionRequirement,
} from "../models/shipping-option-requirement"
export type ShippingRequirement = {
type: RequirementType
amount: number
id: string
}
export type ShippingMethodUpdate = {
data?: any
price?: number
return_id?: string
swap_id?: string
order_id?: string
claim_order_id?: string | null
}
export type CreateShippingMethod = {
data?: any
shipping_option_id?: string
price?: number
return_id?: string
swap_id?: string
cart_id?: string
order_id?: string
draft_order_id?: string
claim_order_id?: string
}
export type CreateShippingMethodDto = CreateShippingMethod & {
cart?: Cart
order?: Order
}
export type CreateShippingOptionInput = {
price_type: ShippingOptionPriceType
name: string
region_id: string
profile_id: string
provider_id: string
data: Record<string, unknown>
amount?: number
is_return?: boolean
admin_only?: boolean
metadata?: Record<string, unknown>
requirements?: ShippingOptionRequirement[]
}
export type UpdateShippingOptionInput = {
metadata?: Record<string, unknown>
price_type?: ShippingOptionPriceType
amount?: number
name?: string
admin_only?: boolean
is_return?: boolean
requirements?: ShippingOptionRequirement[]
region_id?: string
provider_id?: string
data?: string
}