From 78bd61abe1feb1fb8af3b20586a23d3c72da945c Mon Sep 17 00:00:00 2001 From: Adrien de Peretti Date: Thu, 9 Jun 2022 11:29:44 +0200 Subject: [PATCH] Refactor: claim service to TS + refactoring (#1287) --- .../api/routes/admin/orders/create-claim.ts | 30 +- .../api/routes/admin/orders/fulfill-claim.ts | 2 +- .../api/routes/admin/orders/update-claim.ts | 2 +- packages/medusa/src/models/shipping-method.ts | 2 +- .../medusa/src/services/__tests__/claim.js | 5 + .../medusa/src/services/__tests__/swap.js | 1 - packages/medusa/src/services/claim.js | 753 --------------- packages/medusa/src/services/claim.ts | 858 ++++++++++++++++++ packages/medusa/src/types/claim.ts | 89 ++ 9 files changed, 965 insertions(+), 777 deletions(-) delete mode 100644 packages/medusa/src/services/claim.js create mode 100644 packages/medusa/src/services/claim.ts create mode 100644 packages/medusa/src/types/claim.ts diff --git a/packages/medusa/src/api/routes/admin/orders/create-claim.ts b/packages/medusa/src/api/routes/admin/orders/create-claim.ts index afda9f3730..f31c66e09a 100644 --- a/packages/medusa/src/api/routes/admin/orders/create-claim.ts +++ b/packages/medusa/src/api/routes/admin/orders/create-claim.ts @@ -14,6 +14,12 @@ import { MedusaError } from "medusa-core-utils" import { defaultAdminOrdersFields, defaultAdminOrdersRelations } from "." import { AddressPayload } from "../../../../types/common" import { validator } from "../../../../utils/validator" +import { + ClaimItemReason, + ClaimItemReasonValue, + ClaimTypeValue, +} from "../../../../types/claim" +import { ClaimType } from "../../../../models" /** * @oas [post] /order/{id}/claims @@ -332,26 +338,10 @@ export default async (req, res) => { res.status(idempotencyKey.response_code).json(idempotencyKey.response_body) } -enum ClaimTypeEnum { - replace = "replace", - refund = "refund", -} - -type ClaimType = `${ClaimTypeEnum}` - -enum ClaimItemReasonEnum { - missing_item = "missing_item", - wrong_item = "wrong_item", - production_failure = "production_failure", - other = "other", -} - -type ClaimItemReasonType = `${ClaimItemReasonEnum}` - export class AdminPostOrdersOrderClaimsReq { - @IsEnum(ClaimTypeEnum) + @IsEnum(ClaimType) @IsNotEmpty() - type: ClaimType + type: ClaimTypeValue @IsArray() @IsNotEmpty() @@ -432,9 +422,9 @@ class Item { @IsOptional() note?: string - @IsEnum(ClaimItemReasonEnum) + @IsEnum(ClaimItemReason) @IsOptional() - reason?: ClaimItemReasonType + reason?: ClaimItemReasonValue @IsArray() @IsOptional() diff --git a/packages/medusa/src/api/routes/admin/orders/fulfill-claim.ts b/packages/medusa/src/api/routes/admin/orders/fulfill-claim.ts index bdad54cb4e..ad413f2f27 100644 --- a/packages/medusa/src/api/routes/admin/orders/fulfill-claim.ts +++ b/packages/medusa/src/api/routes/admin/orders/fulfill-claim.ts @@ -65,7 +65,7 @@ export default async (req, res) => { export class AdminPostOrdersOrderClaimsClaimFulfillmentsReq { @IsObject() @IsOptional() - metadata?: object + metadata?: Record @IsBoolean() @IsOptional() diff --git a/packages/medusa/src/api/routes/admin/orders/update-claim.ts b/packages/medusa/src/api/routes/admin/orders/update-claim.ts index 6c1f671a2f..ff42936289 100644 --- a/packages/medusa/src/api/routes/admin/orders/update-claim.ts +++ b/packages/medusa/src/api/routes/admin/orders/update-claim.ts @@ -133,7 +133,7 @@ export class AdminPostOrdersOrderClaimsClaimReq { @IsObject() @IsOptional() - metadata?: object + metadata?: Record } class ShippingMethod { diff --git a/packages/medusa/src/models/shipping-method.ts b/packages/medusa/src/models/shipping-method.ts index 7ea26c4888..698a0223b9 100644 --- a/packages/medusa/src/models/shipping-method.ts +++ b/packages/medusa/src/models/shipping-method.ts @@ -44,7 +44,7 @@ export class ShippingMethod { @Index() @Column({ nullable: true }) - claim_order_id: string + claim_order_id: string | null @ManyToOne(() => ClaimOrder) @JoinColumn({ name: "claim_order_id" }) diff --git a/packages/medusa/src/services/__tests__/claim.js b/packages/medusa/src/services/__tests__/claim.js index d008037bbe..19fb11bc0e 100644 --- a/packages/medusa/src/services/__tests__/claim.js +++ b/packages/medusa/src/services/__tests__/claim.js @@ -58,6 +58,10 @@ describe("ClaimService", () => { create: (d) => ({ id: "claim_134", ...d }), }) + const lineItemRepository = MockRepository({ + create: (d) => ({ id: "claim_item_134", ...d }), + }) + const taxProviderService = { createTaxLines: jest.fn(), withTransaction: function () { @@ -103,6 +107,7 @@ describe("ClaimService", () => { const claimService = new ClaimService({ manager: MockManager, claimRepository: claimRepo, + lineItemRepository: lineItemRepository, taxProviderService, totalsService, returnService, diff --git a/packages/medusa/src/services/__tests__/swap.js b/packages/medusa/src/services/__tests__/swap.js index 413714b37c..aaf1a4330e 100644 --- a/packages/medusa/src/services/__tests__/swap.js +++ b/packages/medusa/src/services/__tests__/swap.js @@ -974,7 +974,6 @@ describe("SwapService", () => { const swapService = new SwapService({ manager: MockManager, - eventBusService, swapRepository: swapRepo, paymentProviderService, eventBusService, diff --git a/packages/medusa/src/services/claim.js b/packages/medusa/src/services/claim.js deleted file mode 100644 index 232d3f0e04..0000000000 --- a/packages/medusa/src/services/claim.js +++ /dev/null @@ -1,753 +0,0 @@ -import { MedusaError } from "medusa-core-utils" -import { BaseService } from "medusa-interfaces" - -class ClaimService extends BaseService { - static Events = { - CREATED: "claim.created", - UPDATED: "claim.updated", - CANCELED: "claim.canceled", - FULFILLMENT_CREATED: "claim.fulfillment_created", - SHIPMENT_CREATED: "claim.shipment_created", - REFUND_PROCESSED: "claim.refund_processed", - } - - constructor({ - manager, - addressRepository, - claimItemService, - claimRepository, - eventBusService, - fulfillmentProviderService, - fulfillmentService, - inventoryService, - lineItemService, - paymentProviderService, - regionService, - returnService, - shippingOptionService, - taxProviderService, - totalsService, - }) { - super() - - /** @private @constant {EntityManager} */ - this.manager_ = manager - - this.addressRepo_ = addressRepository - this.claimItemService_ = claimItemService - this.claimRepository_ = claimRepository - this.eventBus_ = eventBusService - this.fulfillmentProviderService_ = fulfillmentProviderService - this.fulfillmentService_ = fulfillmentService - this.inventoryService_ = inventoryService - this.lineItemService_ = lineItemService - this.paymentProviderService_ = paymentProviderService - this.regionService_ = regionService - this.returnService_ = returnService - this.shippingOptionService_ = shippingOptionService - this.taxProviderService_ = taxProviderService - this.totalsService_ = totalsService - } - - withTransaction(manager) { - if (!manager) { - return this - } - - const cloned = new ClaimService({ - manager, - addressRepository: this.addressRepo_, - claimItemService: this.claimItemService_, - claimRepository: this.claimRepository_, - eventBusService: this.eventBus_, - fulfillmentProviderService: this.fulfillmentProviderService_, - fulfillmentService: this.fulfillmentService_, - inventoryService: this.inventoryService_, - lineItemService: this.lineItemService_, - paymentProviderService: this.paymentProviderService_, - regionService: this.regionService_, - returnService: this.returnService_, - shippingOptionService: this.shippingOptionService_, - totalsService: this.totalsService_, - taxProviderService: this.taxProviderService_, - }) - - cloned.transactionManager_ = manager - - return cloned - } - - update(id, data) { - return this.atomicPhase_(async (manager) => { - const claimRepo = manager.getCustomRepository(this.claimRepository_) - const claim = await this.retrieve(id, { relations: ["shipping_methods"] }) - - if (claim.canceled_at) { - throw new MedusaError( - MedusaError.Types.NOT_ALLOWED, - "Canceled claim cannot be updated" - ) - } - - const { claim_items, shipping_methods, metadata, no_notification } = data - - if (metadata) { - claim.metadata = this.setMetadata_(claim, metadata) - await claimRepo.save(claim) - } - - if (shipping_methods) { - for (const m of claim.shipping_methods) { - await this.shippingOptionService_ - .withTransaction(manager) - .updateShippingMethod(m.id, { - claim_order_id: null, - }) - } - - for (const method of shipping_methods) { - if (method.id) { - await this.shippingOptionService_ - .withTransaction(manager) - .updateShippingMethod(method.id, { - claim_order_id: claim.id, - }) - } else { - await this.shippingOptionService_ - .withTransaction(manager) - .createShippingMethod(method.option_id, method.data, { - claim_order_id: claim.id, - price: method.price, - }) - } - } - } - - if (no_notification !== undefined) { - claim.no_notification = no_notification - await claimRepo.save(claim) - } - - if (claim_items) { - for (const i of claim_items) { - if (i.id) { - await this.claimItemService_ - .withTransaction(manager) - .update(i.id, i) - } - } - } - - await this.eventBus_ - .withTransaction(manager) - .emit(ClaimService.Events.UPDATED, { - id: claim.id, - no_notification: claim.no_notification, - }) - - return claim - }) - } - - /** - * Creates a Claim on an Order. Claims consists of items that are claimed and - * optionally items to be sent as replacement for the claimed items. The - * shipping address that the new items will be shipped to - * @param {Object} data - the object containing all data required to create a claim - * @return {Object} created claim - */ - create(data) { - return this.atomicPhase_(async (manager) => { - const claimRepo = manager.getCustomRepository(this.claimRepository_) - - const { - type, - claim_items, - order, - return_shipping, - additional_items, - shipping_methods, - refund_amount, - shipping_address, - shipping_address_id, - no_notification, - ...rest - } = data - - for (const item of claim_items) { - const line = await this.lineItemService_.retrieve(item.item_id, { - relations: ["order", "swap", "claim_order", "tax_lines"], - }) - - if ( - line.order?.canceled_at || - line.swap?.canceled_at || - line.claim_order?.canceled_at - ) { - throw new MedusaError( - MedusaError.Types.INVALID_DATA, - `Cannot create a claim on a canceled item.` - ) - } - } - - let addressId = shipping_address_id || order.shipping_address_id - if (shipping_address) { - const addressRepo = manager.getCustomRepository(this.addressRepo_) - const created = addressRepo.create(shipping_address) - const saved = await addressRepo.save(created) - addressId = saved.id - } - - if (type !== "refund" && type !== "replace") { - throw new MedusaError( - MedusaError.Types.INVALID_DATA, - `Claim type must be one of "refund" or "replace".` - ) - } - - if (type === "replace" && !additional_items?.length) { - throw new MedusaError( - MedusaError.Types.INVALID_DATA, - `Claims with type "replace" must have at least one additional item.` - ) - } - - if (!claim_items?.length) { - throw new MedusaError( - MedusaError.Types.INVALID_DATA, - `Claims must have at least one claim item.` - ) - } - - if (refund_amount && type !== "refund") { - throw new MedusaError( - MedusaError.Types.INVALID_DATA, - `Claim has type "${type}" but must be type "refund" to have a refund_amount.` - ) - } - - let toRefund = refund_amount - if (type === "refund" && typeof refund_amount === "undefined") { - const lines = claim_items.map((ci) => { - const allOrderItems = order.items - - if (order.swaps?.length) { - for (const swap of order.swaps) { - swap.additional_items.forEach((it) => { - if ( - it.shipped_quantity || - it.shipped_quantity === it.fulfilled_quantity - ) { - allOrderItems.push(it) - } - }) - } - } - - if (order.claims?.length) { - for (const claim of order.claims) { - claim.additional_items.forEach((it) => { - if ( - it.shipped_quantity || - it.shipped_quantity === it.fulfilled_quantity - ) { - allOrderItems.push(it) - } - }) - } - } - - const orderItem = allOrderItems.find((oi) => oi.id === ci.item_id) - return { - ...orderItem, - quantity: ci.quantity, - } - }) - toRefund = await this.totalsService_.getRefundTotal(order, lines) - } - - let newItems = [] - if (typeof additional_items !== "undefined") { - for (const item of additional_items) { - await this.inventoryService_ - .withTransaction(manager) - .confirmInventory(item.variant_id, item.quantity) - } - - newItems = await Promise.all( - additional_items.map((i) => - this.lineItemService_ - .withTransaction(manager) - .generate(i.variant_id, order.region_id, i.quantity) - ) - ) - - for (const newItem of newItems) { - await this.inventoryService_ - .withTransaction(manager) - .adjustInventory(newItem.variant_id, -newItem.quantity) - } - } - - const evaluatedNoNotification = - no_notification !== undefined ? no_notification : order.no_notification - - const created = claimRepo.create({ - shipping_address_id: addressId, - payment_status: type === "refund" ? "not_refunded" : "na", - ...rest, - refund_amount: toRefund, - type, - additional_items: newItems, - order_id: order.id, - no_notification: evaluatedNoNotification, - }) - - const result = await claimRepo.save(created) - - if (result.additional_items && result.additional_items.length) { - const calcContext = this.totalsService_.getCalculationContext(order) - const lineItems = await this.lineItemService_ - .withTransaction(manager) - .list({ - id: result.additional_items.map((i) => i.id), - }) - await this.taxProviderService_ - .withTransaction(manager) - .createTaxLines(lineItems, calcContext) - } - - if (shipping_methods) { - for (const method of shipping_methods) { - if (method.id) { - await this.shippingOptionService_ - .withTransaction(manager) - .updateShippingMethod(method.id, { - claim_order_id: result.id, - }) - } else { - await this.shippingOptionService_ - .withTransaction(manager) - .createShippingMethod(method.option_id, method.data, { - claim_order_id: result.id, - price: method.price, - }) - } - } - } - - for (const ci of claim_items) { - await this.claimItemService_.withTransaction(manager).create({ - ...ci, - claim_order_id: result.id, - }) - } - - if (return_shipping) { - await this.returnService_.withTransaction(manager).create({ - order_id: order.id, - claim_order_id: result.id, - items: claim_items.map((ci) => ({ - item_id: ci.item_id, - quantity: ci.quantity, - metadata: ci.metadata, - })), - shipping_method: return_shipping, - no_notification: evaluatedNoNotification, - }) - } - - await this.eventBus_ - .withTransaction(manager) - .emit(ClaimService.Events.CREATED, { - id: result.id, - no_notification: result.no_notification, - }) - - return result - }) - } - /** - * @param {string} id - the object containing all data required to create a claim - * @param {Object} config - config object - * @param {Object | undefined} config.metadata - config metadata - * @param {boolean|undefined} config.no_notification - config no notification - * @return {Claim} created claim - */ - createFulfillment( - id, - config = { - metadata: {}, - no_notification: undefined, - } - ) { - const { metadata, no_notification } = config - - return this.atomicPhase_(async (manager) => { - const claim = await this.retrieve(id, { - relations: [ - "additional_items", - "additional_items.tax_lines", - "shipping_methods", - "shipping_methods.tax_lines", - "shipping_address", - "order", - "order.billing_address", - "order.discounts", - "order.discounts.rule", - "order.payments", - ], - }) - - if (claim.canceled_at) { - throw new MedusaError( - MedusaError.Types.NOT_ALLOWED, - "Canceled claim cannot be fulfilled" - ) - } - - const order = claim.order - - if ( - claim.fulfillment_status !== "not_fulfilled" && - claim.fulfillment_status !== "canceled" - ) { - throw new MedusaError( - MedusaError.Types.NOT_ALLOWED, - "The claim has already been fulfilled." - ) - } - - if (claim.type !== "replace") { - throw new MedusaError( - MedusaError.Types.NOT_ALLOWED, - `Claims with the type "${claim.type}" can not be fulfilled.` - ) - } - - if (!claim.shipping_methods?.length) { - throw new MedusaError( - MedusaError.Types.NOT_ALLOWED, - "Cannot fulfill a claim without a shipping method." - ) - } - - const evaluatedNoNotification = - no_notification !== undefined ? no_notification : claim.no_notification - - const fulfillments = await this.fulfillmentService_ - .withTransaction(manager) - .createFulfillment( - { - ...claim, - email: order.email, - payments: order.payments, - discounts: order.discounts, - currency_code: order.currency_code, - tax_rate: order.tax_rate, - region_id: order.region_id, - display_id: order.display_id, - billing_address: order.billing_address, - items: claim.additional_items, - shipping_methods: claim.shipping_methods, - is_claim: true, - no_notification: evaluatedNoNotification, - }, - claim.additional_items.map((i) => ({ - item_id: i.id, - quantity: i.quantity, - })), - { claim_order_id: id, metadata } - ) - - let successfullyFulfilled = [] - for (const f of fulfillments) { - successfullyFulfilled = successfullyFulfilled.concat(f.items) - } - - claim.fulfillment_status = "fulfilled" - - for (const item of claim.additional_items) { - const fulfillmentItem = successfullyFulfilled.find( - (f) => item.id === f.item_id - ) - - if (fulfillmentItem) { - const fulfilledQuantity = - (item.fulfilled_quantity || 0) + fulfillmentItem.quantity - - // Update the fulfilled quantity - await this.lineItemService_.withTransaction(manager).update(item.id, { - fulfilled_quantity: fulfilledQuantity, - }) - - if (item.quantity !== fulfilledQuantity) { - claim.fulfillment_status = "requires_action" - } - } else { - if (item.quantity !== item.fulfilled_quantity) { - claim.fulfillment_status = "requires_action" - } - } - } - - const claimRepo = manager.getCustomRepository(this.claimRepository_) - const result = await claimRepo.save(claim) - - for (const fulfillment of fulfillments) { - await this.eventBus_ - .withTransaction(manager) - .emit(ClaimService.Events.FULFILLMENT_CREATED, { - id: id, - fulfillment_id: fulfillment.id, - no_notification: claim.no_notification, - }) - } - - return result - }) - } - - async cancelFulfillment(fulfillmentId) { - return this.atomicPhase_(async (manager) => { - const canceled = await this.fulfillmentService_ - .withTransaction(manager) - .cancelFulfillment(fulfillmentId) - - if (!canceled.claim_order_id) { - throw new MedusaError( - MedusaError.Types.NOT_ALLOWED, - `Fufillment not related to a claim` - ) - } - - const claim = await this.retrieve(canceled.claim_order_id) - - claim.fulfillment_status = "canceled" - - const claimRepo = manager.getCustomRepository(this.claimRepository_) - const updated = await claimRepo.save(claim) - return updated - }) - } - - async processRefund(id) { - return this.atomicPhase_(async (manager) => { - const claim = await this.retrieve(id, { - relations: ["order", "order.payments"], - }) - - if (claim.canceled_at) { - throw new MedusaError( - MedusaError.Types.NOT_ALLOWED, - "Canceled claim cannot be processed" - ) - } - - if (claim.type !== "refund") { - throw new MedusaError( - MedusaError.Types.NOT_ALLOWED, - `Claim must have type "refund" to create a refund.` - ) - } - - if (claim.refund_amount) { - await this.paymentProviderService_ - .withTransaction(manager) - .refundPayment(claim.order.payments, claim.refund_amount, "claim") - } - - claim.payment_status = "refunded" - - const claimRepo = manager.getCustomRepository(this.claimRepository_) - const result = await claimRepo.save(claim) - - await this.eventBus_ - .withTransaction(manager) - .emit(ClaimService.Events.REFUND_PROCESSED, { - id, - no_notification: result.no_notification, - }) - - return result - }) - } - - async createShipment( - id, - fulfillmentId, - trackingLinks, - config = { - metadata: {}, - no_notification: undefined, - } - ) { - const { metadata, no_notification } = config - - return this.atomicPhase_(async (manager) => { - const claim = await this.retrieve(id, { - relations: ["additional_items"], - }) - - if (claim.canceled_at) { - throw new MedusaError( - MedusaError.Types.NOT_ALLOWED, - "Canceled claim cannot be fulfilled as shipped" - ) - } - const evaluatedNoNotification = - no_notification !== undefined ? no_notification : claim.no_notification - - const shipment = await this.fulfillmentService_ - .withTransaction(manager) - .createShipment(fulfillmentId, trackingLinks, { - metadata, - no_notification: evaluatedNoNotification, - }) - - claim.fulfillment_status = "shipped" - - for (const i of claim.additional_items) { - const shipped = shipment.items.find((si) => si.item_id === i.id) - if (shipped) { - const shippedQty = (i.shipped_quantity || 0) + shipped.quantity - await this.lineItemService_.withTransaction(manager).update(i.id, { - shipped_quantity: shippedQty, - }) - - if (shippedQty !== i.quantity) { - claim.fulfillment_status = "partially_shipped" - } - } else { - if (i.shipped_quantity !== i.quantity) { - claim.fulfillment_status = "partially_shipped" - } - } - } - - const claimRepo = manager.getCustomRepository(this.claimRepository_) - const result = await claimRepo.save(claim) - - await this.eventBus_ - .withTransaction(manager) - .emit(ClaimService.Events.SHIPMENT_CREATED, { - id, - fulfillment_id: shipment.id, - no_notification: evaluatedNoNotification, - }) - - return result - }) - } - - async cancel(id) { - return this.atomicPhase_(async (manager) => { - const claim = await this.retrieve(id, { - relations: ["return_order", "fulfillments", "order", "order.refunds"], - }) - if (claim.refund_amount) { - throw new MedusaError( - MedusaError.Types.NOT_ALLOWED, - "Claim with a refund cannot be canceled" - ) - } - - if (claim.fulfillments) { - for (const f of claim.fulfillments) { - if (!f.canceled_at) { - throw new MedusaError( - MedusaError.Types.NOT_ALLOWED, - "All fulfillments must be canceled before the claim can be canceled" - ) - } - } - } - - if (claim.return_order && claim.return_order.status !== "canceled") { - throw new MedusaError( - MedusaError.Types.NOT_ALLOWED, - "Return must be canceled before the claim can be canceled" - ) - } - - claim.fulfillment_status = "canceled" - claim.canceled_at = new Date() - - const claimRepo = manager.getCustomRepository(this.claimRepository_) - const result = await claimRepo.save(claim) - - await this.eventBus_ - .withTransaction(manager) - .emit(ClaimService.Events.CANCELED, { - id: result.id, - no_notification: result.no_notification, - }) - - return result - }) - } - - /** - * @param {Object} selector - the query object for find - * @param {Object} config - the config object containing query settings - * @return {Promise} the result of the find operation - */ - async list( - selector, - config = { skip: 0, take: 50, order: { created_at: "DESC" } } - ) { - const claimRepo = this.manager_.getCustomRepository(this.claimRepository_) - const query = this.buildQuery_(selector, config) - return claimRepo.find(query) - } - - /** - * Gets an order by id. - * @param {string} claimId - id of order to retrieve - * @param {Object} config - the config object containing query settings - * @return {Promise} the order document - */ - async retrieve(claimId, config = {}) { - const claimRepo = this.manager_.getCustomRepository(this.claimRepository_) - const validatedId = this.validateId_(claimId) - - const query = this.buildQuery_({ id: validatedId }, config) - const claim = await claimRepo.findOne(query) - - if (!claim) { - throw new MedusaError( - MedusaError.Types.NOT_FOUND, - `Claim with ${claimId} was not found` - ) - } - - return claim - } - - /** - * Dedicated method to delete metadata for an order. - * @param {string} orderId - the order to delete metadata from. - * @param {string} key - key for metadata field - * @return {Promise} resolves to the updated result. - */ - async deleteMetadata(orderId, key) { - const validatedId = this.validateId_(orderId) - - if (typeof key !== "string") { - throw new MedusaError( - MedusaError.Types.INVALID_ARGUMENT, - "Key type is invalid. Metadata keys must be strings" - ) - } - - const keyPath = `metadata.${key}` - return this.orderModel_ - .updateOne({ _id: validatedId }, { $unset: { [keyPath]: "" } }) - .catch((err) => { - throw new MedusaError(MedusaError.Types.DB_ERROR, err.message) - }) - } -} - -export default ClaimService diff --git a/packages/medusa/src/services/claim.ts b/packages/medusa/src/services/claim.ts new file mode 100644 index 0000000000..0e5ba19eb6 --- /dev/null +++ b/packages/medusa/src/services/claim.ts @@ -0,0 +1,858 @@ +import ClaimItemService from "./claim-item" +import EventBusService from "./event-bus" +import FulfillmentProviderService from "./fulfillment-provider" +import FulfillmentService from "./fulfillment" +import InventoryService from "./inventory" +import LineItemService from "./line-item" +import PaymentProviderService from "./payment-provider" +import RegionService from "./region" +import ReturnService from "./return" +import ShippingOptionService from "./shipping-option" +import TaxProviderService from "./tax-provider" +import TotalsService from "./totals" +import { AddressRepository } from "../repositories/address" +import { + ClaimFulfillmentStatus, + ClaimOrder, + ClaimPaymentStatus, + ClaimType, + FulfillmentItem, + LineItem, +} from "../models" +import { ClaimRepository } from "../repositories/claim" +import { DeepPartial, EntityManager } from "typeorm" +import { LineItemRepository } from "../repositories/line-item" +import { MedusaError } from "medusa-core-utils" +import { ShippingMethodRepository } from "../repositories/shipping-method" +import { TransactionBaseService } from "../interfaces" +import { buildQuery, setMetadata } from "../utils" +import { FindConfig } from "../types/common" +import { CreateClaimInput, UpdateClaimInput } from "../types/claim" + +type InjectedDependencies = { + manager: EntityManager + addressRepository: typeof AddressRepository + shippingMethodRepository: typeof ShippingMethodRepository + lineItemRepository: typeof LineItemRepository + claimRepository: typeof ClaimRepository + claimItemService: ClaimItemService + eventBusService: EventBusService + fulfillmentProviderService: FulfillmentProviderService + fulfillmentService: FulfillmentService + inventoryService: InventoryService + lineItemService: LineItemService + paymentProviderService: PaymentProviderService + regionService: RegionService + returnService: ReturnService + shippingOptionService: ShippingOptionService + taxProviderService: TaxProviderService + totalsService: TotalsService +} + +export default class ClaimService extends TransactionBaseService< + ClaimService, + InjectedDependencies +> { + static readonly Events = { + CREATED: "claim.created", + UPDATED: "claim.updated", + CANCELED: "claim.canceled", + FULFILLMENT_CREATED: "claim.fulfillment_created", + SHIPMENT_CREATED: "claim.shipment_created", + REFUND_PROCESSED: "claim.refund_processed", + } + + protected manager_: EntityManager + protected transactionManager_: EntityManager | undefined + + protected readonly addressRepository_: typeof AddressRepository + protected readonly claimRepository_: typeof ClaimRepository + protected readonly shippingMethodRepository_: typeof ShippingMethodRepository + protected readonly lineItemRepository_: typeof LineItemRepository + protected readonly claimItemService_: ClaimItemService + protected readonly eventBus_: EventBusService + protected readonly fulfillmentProviderService_: FulfillmentProviderService + protected readonly fulfillmentService_: FulfillmentService + protected readonly inventoryService_: InventoryService + protected readonly lineItemService_: LineItemService + protected readonly paymentProviderService_: PaymentProviderService + protected readonly regionService_: RegionService + protected readonly returnService_: ReturnService + protected readonly shippingOptionService_: ShippingOptionService + protected readonly taxProviderService_: TaxProviderService + protected readonly totalsService_: TotalsService + + constructor({ + manager, + addressRepository, + claimRepository, + shippingMethodRepository, + lineItemRepository, + claimItemService, + eventBusService, + fulfillmentProviderService, + fulfillmentService, + inventoryService, + lineItemService, + paymentProviderService, + regionService, + returnService, + shippingOptionService, + taxProviderService, + totalsService, + }: InjectedDependencies) { + // eslint-disable-next-line prefer-rest-params + super(arguments[0]) + + this.manager_ = manager + + this.addressRepository_ = addressRepository + this.claimRepository_ = claimRepository + this.shippingMethodRepository_ = shippingMethodRepository + this.lineItemRepository_ = lineItemRepository + this.claimItemService_ = claimItemService + this.eventBus_ = eventBusService + this.fulfillmentProviderService_ = fulfillmentProviderService + this.fulfillmentService_ = fulfillmentService + this.inventoryService_ = inventoryService + this.lineItemService_ = lineItemService + this.paymentProviderService_ = paymentProviderService + this.regionService_ = regionService + this.returnService_ = returnService + this.shippingOptionService_ = shippingOptionService + this.taxProviderService_ = taxProviderService + this.totalsService_ = totalsService + } + + async update(id: string, data: UpdateClaimInput): Promise { + return await this.atomicPhase_( + async (transactionManager: EntityManager) => { + const claimRepo = transactionManager.getCustomRepository( + this.claimRepository_ + ) + const claim = await this.retrieve(id, { + relations: ["shipping_methods"], + }) + + if (claim.canceled_at) { + throw new MedusaError( + MedusaError.Types.NOT_ALLOWED, + "Canceled claim cannot be updated" + ) + } + + const { claim_items, shipping_methods, metadata, no_notification } = + data + + if (metadata) { + claim.metadata = setMetadata(claim, metadata) + await claimRepo.save(claim) + } + + if (shipping_methods) { + for (const m of claim.shipping_methods) { + await this.shippingOptionService_ + .withTransaction(transactionManager) + .updateShippingMethod(m.id, { + claim_order_id: null, + }) + } + + for (const method of shipping_methods) { + if (method.id) { + await this.shippingOptionService_ + .withTransaction(transactionManager) + .updateShippingMethod(method.id, { + claim_order_id: claim.id, + }) + } else { + await this.shippingOptionService_ + .withTransaction(transactionManager) + .createShippingMethod( + method.option_id as string, + (method as any).data, + { + claim_order_id: claim.id, + price: method.price, + } + ) + } + } + } + + if (no_notification !== undefined) { + claim.no_notification = no_notification + await claimRepo.save(claim) + } + + if (claim_items) { + for (const i of claim_items) { + if (i.id) { + await this.claimItemService_ + .withTransaction(transactionManager) + .update(i.id, i) + } + } + } + + await this.eventBus_ + .withTransaction(transactionManager) + .emit(ClaimService.Events.UPDATED, { + id: claim.id, + no_notification: claim.no_notification, + }) + + return claim + } + ) + } + + /** + * Creates a Claim on an Order. Claims consists of items that are claimed and + * optionally items to be sent as replacement for the claimed items. The + * shipping address that the new items will be shipped to + * @param data - the object containing all data required to create a claim + * @return created claim + */ + async create(data: CreateClaimInput): Promise { + return await this.atomicPhase_( + async (transactionManager: EntityManager) => { + const claimRepo = transactionManager.getCustomRepository( + this.claimRepository_ + ) + + const { + type, + claim_items, + order, + return_shipping, + additional_items, + shipping_methods, + refund_amount, + shipping_address, + shipping_address_id, + no_notification, + ...rest + } = data + + for (const item of claim_items) { + const line = await this.lineItemService_ + .withTransaction(transactionManager) + .retrieve(item.item_id, { + relations: ["order", "swap", "claim_order", "tax_lines"], + }) + + if ( + line.order?.canceled_at || + line.swap?.canceled_at || + line.claim_order?.canceled_at + ) { + throw new MedusaError( + MedusaError.Types.INVALID_DATA, + `Cannot create a claim on a canceled item.` + ) + } + } + + let addressId = shipping_address_id || order.shipping_address_id + if (shipping_address) { + const addressRepo = transactionManager.getCustomRepository( + this.addressRepository_ + ) + const created = addressRepo.create(shipping_address) + const saved = await addressRepo.save(created) + addressId = saved.id + } + + if (type !== ClaimType.REFUND && type !== ClaimType.REPLACE) { + throw new MedusaError( + MedusaError.Types.INVALID_DATA, + `Claim type must be one of "refund" or "replace".` + ) + } + + if (type === ClaimType.REPLACE && !additional_items?.length) { + throw new MedusaError( + MedusaError.Types.INVALID_DATA, + `Claims with type "replace" must have at least one additional item.` + ) + } + + if (!claim_items?.length) { + throw new MedusaError( + MedusaError.Types.INVALID_DATA, + `Claims must have at least one claim item.` + ) + } + + if (refund_amount && type !== ClaimType.REFUND) { + throw new MedusaError( + MedusaError.Types.INVALID_DATA, + `Claim has type "${type}" but must be type "refund" to have a refund_amount.` + ) + } + + let toRefund = refund_amount + if (type === ClaimType.REFUND && typeof refund_amount === "undefined") { + const lines = claim_items.map((ci) => { + const allOrderItems = order.items + + if (order.swaps?.length) { + for (const swap of order.swaps) { + swap.additional_items.forEach((it) => { + if ( + it.shipped_quantity || + it.shipped_quantity === it.fulfilled_quantity + ) { + allOrderItems.push(it) + } + }) + } + } + + if (order.claims?.length) { + for (const claim of order.claims) { + claim.additional_items.forEach((it) => { + if ( + it.shipped_quantity || + it.shipped_quantity === it.fulfilled_quantity + ) { + allOrderItems.push(it) + } + }) + } + } + + const orderItem = allOrderItems.find((oi) => oi.id === ci.item_id) + return { + ...orderItem, + quantity: ci.quantity, + } + }) + toRefund = await this.totalsService_.getRefundTotal( + order, + lines as LineItem[] + ) + } + + let newItems: LineItem[] = [] + if (typeof additional_items !== "undefined") { + for (const item of additional_items) { + await this.inventoryService_ + .withTransaction(transactionManager) + .confirmInventory(item.variant_id, item.quantity) + } + + newItems = await Promise.all( + additional_items.map((i) => + this.lineItemService_ + .withTransaction(transactionManager) + .generate(i.variant_id, order.region_id, i.quantity) + ) + ) + + for (const newItem of newItems) { + await this.inventoryService_ + .withTransaction(transactionManager) + .adjustInventory(newItem.variant_id, -newItem.quantity) + } + } + + const evaluatedNoNotification = + no_notification !== undefined + ? no_notification + : order.no_notification + + const created = claimRepo.create({ + shipping_address_id: addressId, + payment_status: type === ClaimType.REFUND ? "not_refunded" : "na", + refund_amount: toRefund, + type, + additional_items: newItems, + order_id: order.id, + no_notification: evaluatedNoNotification, + ...rest, + } as DeepPartial) + + const result: ClaimOrder = await claimRepo.save(created) + + if (result.additional_items && result.additional_items.length) { + const calcContext = this.totalsService_.getCalculationContext(order) + const lineItems = await this.lineItemService_ + .withTransaction(transactionManager) + .list({ + id: result.additional_items.map((i) => i.id), + }) + await this.taxProviderService_ + .withTransaction(transactionManager) + .createTaxLines(lineItems, calcContext) + } + + if (shipping_methods) { + for (const method of shipping_methods) { + if (method.id) { + await this.shippingOptionService_ + .withTransaction(transactionManager) + .updateShippingMethod(method.id, { + claim_order_id: result.id, + }) + } else { + await this.shippingOptionService_ + .withTransaction(transactionManager) + .createShippingMethod( + method.option_id as string, + (method as any).data, + { + claim_order_id: result.id, + price: method.price, + } + ) + } + } + } + + for (const ci of claim_items) { + await this.claimItemService_ + .withTransaction(transactionManager) + .create({ + ...ci, + claim_order_id: result.id, + }) + } + + if (return_shipping) { + await this.returnService_.withTransaction(transactionManager).create({ + order_id: order.id, + claim_order_id: result.id, + items: claim_items.map((ci) => ({ + item_id: ci.item_id, + quantity: ci.quantity, + metadata: (ci as any).metadata, + })), + shipping_method: return_shipping, + no_notification: evaluatedNoNotification, + }) + } + + await this.eventBus_ + .withTransaction(transactionManager) + .emit(ClaimService.Events.CREATED, { + id: result.id, + no_notification: result.no_notification, + }) + + return result + } + ) + } + + /** + * @param id - the object containing all data required to create a claim + * @param config - config object + * @param config.metadata - config metadata + * @param config.no_notification - config no notification + * @return created claim + */ + async createFulfillment( + id: string, + config: { + metadata?: Record + no_notification?: boolean + } = { + metadata: {}, + } + ): Promise { + const { metadata, no_notification } = config + + return await this.atomicPhase_( + async (transactionManager: EntityManager) => { + const claim = await this.retrieve(id, { + relations: [ + "additional_items", + "additional_items.tax_lines", + "shipping_methods", + "shipping_methods.tax_lines", + "shipping_address", + "order", + "order.billing_address", + "order.discounts", + "order.discounts.rule", + "order.payments", + ], + }) + + if (claim.canceled_at) { + throw new MedusaError( + MedusaError.Types.NOT_ALLOWED, + "Canceled claim cannot be fulfilled" + ) + } + + const order = claim.order + + if ( + claim.fulfillment_status !== "not_fulfilled" && + claim.fulfillment_status !== "canceled" + ) { + throw new MedusaError( + MedusaError.Types.NOT_ALLOWED, + "The claim has already been fulfilled." + ) + } + + if (claim.type !== "replace") { + throw new MedusaError( + MedusaError.Types.NOT_ALLOWED, + `Claims with the type "${claim.type}" can not be fulfilled.` + ) + } + + if (!claim.shipping_methods?.length) { + throw new MedusaError( + MedusaError.Types.NOT_ALLOWED, + "Cannot fulfill a claim without a shipping method." + ) + } + + const evaluatedNoNotification = + no_notification !== undefined + ? no_notification + : claim.no_notification + + const fulfillments = await this.fulfillmentService_ + .withTransaction(transactionManager) + .createFulfillment( + { + ...claim, + email: order.email, + payments: order.payments, + discounts: order.discounts, + currency_code: order.currency_code, + tax_rate: order.tax_rate, + region_id: order.region_id, + display_id: order.display_id, + billing_address: order.billing_address, + items: claim.additional_items, + shipping_methods: claim.shipping_methods, + is_claim: true, + no_notification: evaluatedNoNotification, + }, + claim.additional_items.map((i) => ({ + item_id: i.id, + quantity: i.quantity, + })), + { claim_order_id: id, metadata } + ) + + let successfullyFulfilledItems: FulfillmentItem[] = [] + for (const fulfillment of fulfillments) { + successfullyFulfilledItems = successfullyFulfilledItems.concat( + fulfillment.items + ) + } + + claim.fulfillment_status = ClaimFulfillmentStatus.FULFILLED + + for (const item of claim.additional_items) { + const fulfillmentItem = successfullyFulfilledItems.find( + (successfullyFulfilledItem) => { + return successfullyFulfilledItem.item_id === item.id + } + ) + + if (fulfillmentItem) { + const fulfilledQuantity = + (item.fulfilled_quantity || 0) + fulfillmentItem.quantity + + // Update the fulfilled quantity + await this.lineItemService_ + .withTransaction(transactionManager) + .update(item.id, { + fulfilled_quantity: fulfilledQuantity, + }) + + if (item.quantity !== fulfilledQuantity) { + claim.fulfillment_status = ClaimFulfillmentStatus.REQUIRES_ACTION + } + } else if (item.quantity !== item.fulfilled_quantity) { + claim.fulfillment_status = ClaimFulfillmentStatus.REQUIRES_ACTION + } + } + + const claimRepo = transactionManager.getCustomRepository( + this.claimRepository_ + ) + const claimOrder = await claimRepo.save(claim) + + for (const fulfillment of fulfillments) { + await this.eventBus_ + .withTransaction(transactionManager) + .emit(ClaimService.Events.FULFILLMENT_CREATED, { + id: id, + fulfillment_id: fulfillment.id, + no_notification: claim.no_notification, + }) + } + + return claimOrder + } + ) + } + + async cancelFulfillment(fulfillmentId: string): Promise { + return await this.atomicPhase_( + async (transactionManager: EntityManager) => { + const canceled = await this.fulfillmentService_ + .withTransaction(transactionManager) + .cancelFulfillment(fulfillmentId) + + if (!canceled.claim_order_id) { + throw new MedusaError( + MedusaError.Types.NOT_ALLOWED, + `Fufillment not related to a claim` + ) + } + + const claim = await this.retrieve(canceled.claim_order_id) + + claim.fulfillment_status = ClaimFulfillmentStatus.CANCELED + + const claimRepo = transactionManager.getCustomRepository( + this.claimRepository_ + ) + return claimRepo.save(claim) + } + ) + } + + async processRefund(id: string): Promise { + return await this.atomicPhase_( + async (transactionManager: EntityManager) => { + const claim = await this.retrieve(id, { + relations: ["order", "order.payments"], + }) + + if (claim.canceled_at) { + throw new MedusaError( + MedusaError.Types.NOT_ALLOWED, + "Canceled claim cannot be processed" + ) + } + + if (claim.type !== "refund") { + throw new MedusaError( + MedusaError.Types.NOT_ALLOWED, + `Claim must have type "refund" to create a refund.` + ) + } + + if (claim.refund_amount) { + await this.paymentProviderService_ + .withTransaction(transactionManager) + .refundPayment(claim.order.payments, claim.refund_amount, "claim") + } + + claim.payment_status = ClaimPaymentStatus.REFUNDED + + const claimRepo = transactionManager.getCustomRepository( + this.claimRepository_ + ) + const claimOrder = await claimRepo.save(claim) + + await this.eventBus_ + .withTransaction(transactionManager) + .emit(ClaimService.Events.REFUND_PROCESSED, { + id, + no_notification: claimOrder.no_notification, + }) + + return claimOrder + } + ) + } + + async createShipment( + id: string, + fulfillmentId: string, + trackingLinks: { tracking_number: string }[] = [], + config = { + metadata: {}, + no_notification: undefined, + } + ): Promise { + const { metadata, no_notification } = config + + return await this.atomicPhase_( + async (transactionManager: EntityManager) => { + const claim = await this.retrieve(id, { + relations: ["additional_items"], + }) + + if (claim.canceled_at) { + throw new MedusaError( + MedusaError.Types.NOT_ALLOWED, + "Canceled claim cannot be fulfilled as shipped" + ) + } + const evaluatedNoNotification = + no_notification !== undefined + ? no_notification + : claim.no_notification + + const shipment = await this.fulfillmentService_ + .withTransaction(transactionManager) + .createShipment(fulfillmentId, trackingLinks, { + metadata, + no_notification: evaluatedNoNotification, + }) + + claim.fulfillment_status = ClaimFulfillmentStatus.SHIPPED + + for (const additionalItem of claim.additional_items) { + const shipped = shipment.items.find( + (si) => si.item_id === additionalItem.id + ) + if (shipped) { + const shippedQty = + (additionalItem.shipped_quantity || 0) + shipped.quantity + await this.lineItemService_ + .withTransaction(transactionManager) + .update(additionalItem.id, { + shipped_quantity: shippedQty, + }) + + if (shippedQty !== additionalItem.quantity) { + claim.fulfillment_status = + ClaimFulfillmentStatus.PARTIALLY_SHIPPED + } + } else if ( + additionalItem.shipped_quantity !== additionalItem.quantity + ) { + claim.fulfillment_status = ClaimFulfillmentStatus.PARTIALLY_SHIPPED + } + } + + const claimRepo = transactionManager.getCustomRepository( + this.claimRepository_ + ) + const claimOrder = await claimRepo.save(claim) + + await this.eventBus_ + .withTransaction(transactionManager) + .emit(ClaimService.Events.SHIPMENT_CREATED, { + id, + fulfillment_id: shipment.id, + no_notification: evaluatedNoNotification, + }) + + return claimOrder + } + ) + } + + async cancel(id: string): Promise { + return await this.atomicPhase_( + async (transactionManager: EntityManager) => { + const claim = await this.retrieve(id, { + relations: ["return_order", "fulfillments", "order", "order.refunds"], + }) + if (claim.refund_amount) { + throw new MedusaError( + MedusaError.Types.NOT_ALLOWED, + "Claim with a refund cannot be canceled" + ) + } + + if (claim.fulfillments) { + for (const f of claim.fulfillments) { + if (!f.canceled_at) { + throw new MedusaError( + MedusaError.Types.NOT_ALLOWED, + "All fulfillments must be canceled before the claim can be canceled" + ) + } + } + } + + if (claim.return_order && claim.return_order.status !== "canceled") { + throw new MedusaError( + MedusaError.Types.NOT_ALLOWED, + "Return must be canceled before the claim can be canceled" + ) + } + + claim.fulfillment_status = ClaimFulfillmentStatus.CANCELED + claim.canceled_at = new Date() + + const claimRepo = transactionManager.getCustomRepository( + this.claimRepository_ + ) + const claimOrder = await claimRepo.save(claim) + + await this.eventBus_ + .withTransaction(transactionManager) + .emit(ClaimService.Events.CANCELED, { + id: claimOrder.id, + no_notification: claimOrder.no_notification, + }) + + return claimOrder + } + ) + } + + /** + * @param selector - the query object for find + * @param config - the config object containing query settings + * @return the result of the find operation + */ + async list( + selector, + config: FindConfig = { + skip: 0, + take: 50, + order: { created_at: "DESC" }, + } + ): Promise { + return await this.atomicPhase_( + async (transactionManager: EntityManager) => { + const claimRepo = transactionManager.getCustomRepository( + this.claimRepository_ + ) + const query = buildQuery(selector, config) + return await claimRepo.find(query) + } + ) + } + + /** + * Gets an order by id. + * @param id - id of the claim order to retrieve + * @param config - the config object containing query settings + * @return the order document + */ + async retrieve( + id: string, + config: FindConfig = {} + ): Promise { + return await this.atomicPhase_( + async (transactionManager: EntityManager) => { + const claimRepo = transactionManager.getCustomRepository( + this.claimRepository_ + ) + + const query = buildQuery({ id }, config) + const claim = await claimRepo.findOne(query) + + if (!claim) { + throw new MedusaError( + MedusaError.Types.NOT_FOUND, + `Claim with ${id} was not found` + ) + } + + return claim + } + ) + } +} diff --git a/packages/medusa/src/types/claim.ts b/packages/medusa/src/types/claim.ts new file mode 100644 index 0000000000..d8f96f75be --- /dev/null +++ b/packages/medusa/src/types/claim.ts @@ -0,0 +1,89 @@ +import { ClaimType, Order } from "../models" +import { AddressPayload } from "./common" + +export type ClaimTypeValue = `${ClaimType}` + +export enum ClaimItemReason { + missing_item = "missing_item", + wrong_item = "wrong_item", + production_failure = "production_failure", + other = "other", +} + +export type ClaimItemReasonValue = `${ClaimItemReason}` + +/* CREATE INPUT */ + +export type CreateClaimInput = { + type: ClaimTypeValue + claim_items: CreateClaimItemInput[] + return_shipping?: CreateClaimReturnShippingInput + additional_items?: CreateClaimItemAdditionalItemInput[] + shipping_methods?: CreateClaimShippingMethodInput[] + refund_amount?: number + shipping_address?: AddressPayload + no_notification?: boolean + metadata?: object + order: Order + claim_order_id?: string + shipping_address_id?: string +} + +type CreateClaimReturnShippingInput = { + option_id?: string + price?: number +} + +type CreateClaimShippingMethodInput = { + id?: string + option_id?: string + price?: number +} + +type CreateClaimItemInput = { + item_id: string + quantity: number + note?: string + reason?: ClaimItemReasonValue + tags?: string[] + images?: string[] +} + +type CreateClaimItemAdditionalItemInput = { + variant_id: string + quantity: number +} + +/* UPDATE INPUT */ + +export type UpdateClaimInput = { + claim_items?: UpdateClaimItemInput[] + shipping_methods?: UpdateClaimShippingMethodInput[] + no_notification?: boolean + metadata?: Record +} + +type UpdateClaimShippingMethodInput = { + id?: string + option_id?: string + price?: number +} + +type UpdateClaimItemInput = { + id: string + note?: string + reason?: string + images: UpdateClaimItemImageInput[] + tags: UpdateClaimItemTagInput[] + metadata?: object +} + +type UpdateClaimItemImageInput = { + id?: string + url?: string +} + +type UpdateClaimItemTagInput = { + id?: string + value?: string +}