From b478b10438107bf273d1a2b5af9ac29be8c0d858 Mon Sep 17 00:00:00 2001 From: Philip Korsholm <88927411+pKorsholm@users.noreply.github.com> Date: Mon, 13 Jun 2022 09:55:50 +0200 Subject: [PATCH] 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 --- packages/medusa/src/models/shipping-option.ts | 2 +- .../src/services/__tests__/shipping-option.js | 2 +- packages/medusa/src/services/cart.ts | 9 +- .../medusa/src/services/shipping-option.ts | 698 ++++++++++++++++++ packages/medusa/src/types/shipping-options.ts | 66 ++ 5 files changed, 774 insertions(+), 3 deletions(-) create mode 100644 packages/medusa/src/services/shipping-option.ts create mode 100644 packages/medusa/src/types/shipping-options.ts diff --git a/packages/medusa/src/models/shipping-option.ts b/packages/medusa/src/models/shipping-option.ts index 989ad22dbd..d42ffd2861 100644 --- a/packages/medusa/src/models/shipping-option.ts +++ b/packages/medusa/src/models/shipping-option.ts @@ -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 diff --git a/packages/medusa/src/services/__tests__/shipping-option.js b/packages/medusa/src/services/__tests__/shipping-option.js index 45dae50bca..2a30f9aa2b 100644 --- a/packages/medusa/src/services/__tests__/shipping-option.js +++ b/packages/medusa/src/services/__tests__/shipping-option.js @@ -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) diff --git a/packages/medusa/src/services/cart.ts b/packages/medusa/src/services/cart.ts index e05e2b6310..bd2797fa8d 100644 --- a/packages/medusa/src/services/cart.ts +++ b/packages/medusa/src/services/cart.ts @@ -705,7 +705,14 @@ class CartService extends TransactionBaseService { 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) }) ) diff --git a/packages/medusa/src/services/shipping-option.ts b/packages/medusa/src/services/shipping-option.ts new file mode 100644 index 0000000000..4668f0ede6 --- /dev/null +++ b/packages/medusa/src/services/shipping-option.ts @@ -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 { + 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 { + 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, + config: FindConfig = { skip: 0, take: 50 } + ): Promise { + 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, + config: FindConfig = { 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} the profile document. + */ + async retrieve( + optionId, + options: { select?: (keyof ShippingOption)[]; relations?: string[] } = {} + ): Promise { + return await this.atomicPhase_(async (transactionManager) => { + const soRepo: ShippingOptionRepository = + transactionManager.getCustomRepository(this.optionRepository_) + + const query: ExtendedFindConfig = { + 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} the resulting shipping method + */ + async updateShippingMethod( + id: string, + update: ShippingMethodUpdate + ): Promise { + 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} shippingMethods - the shipping method to remove + * @returns removed shipping methods + */ + async deleteShippingMethods( + shippingMethods: ShippingMethod | ShippingMethod[] + ): Promise { + 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 { + 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 = { + 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} the result of the create operation + */ + async create(data: CreateShippingOptionInput): Promise { + return this.atomicPhase_(async (manager) => { + const optionRepo = manager.getCustomRepository(this.optionRepository_) + const option = await optionRepo.create( + data as DeepPartial + ) + + 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} the validated price + */ + async validatePriceType_( + priceType: ShippingOptionPriceType, + option: ShippingOption + ): Promise { + 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 { + 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 { + 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 { + 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 { + 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} the price of the shipping option. + */ + async getPrice_( + option: ShippingOption, + data: object, + cart: Cart | Order | undefined + ): Promise { + if (option.price_type === "calculated") { + return this.providerService_.calculatePrice(option, data, cart) + } + return option.amount as number + } +} + +export default ShippingOptionService diff --git a/packages/medusa/src/types/shipping-options.ts b/packages/medusa/src/types/shipping-options.ts new file mode 100644 index 0000000000..ebf75019f9 --- /dev/null +++ b/packages/medusa/src/types/shipping-options.ts @@ -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 + + amount?: number + is_return?: boolean + admin_only?: boolean + metadata?: Record + requirements?: ShippingOptionRequirement[] +} + +export type UpdateShippingOptionInput = { + metadata?: Record + price_type?: ShippingOptionPriceType + amount?: number + name?: string + admin_only?: boolean + is_return?: boolean + requirements?: ShippingOptionRequirement[] + region_id?: string + provider_id?: string + data?: string +}