Refactor: claim service to TS + refactoring (#1287)

This commit is contained in:
Adrien de Peretti
2022-06-09 11:29:44 +02:00
committed by GitHub
parent 63bf684e66
commit 78bd61abe1
9 changed files with 965 additions and 777 deletions
@@ -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()
@@ -65,7 +65,7 @@ export default async (req, res) => {
export class AdminPostOrdersOrderClaimsClaimFulfillmentsReq {
@IsObject()
@IsOptional()
metadata?: object
metadata?: Record<string, unknown>
@IsBoolean()
@IsOptional()
@@ -133,7 +133,7 @@ export class AdminPostOrdersOrderClaimsClaimReq {
@IsObject()
@IsOptional()
metadata?: object
metadata?: Record<string, unknown>
}
class ShippingMethod {
@@ -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" })
@@ -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,
@@ -974,7 +974,6 @@ describe("SwapService", () => {
const swapService = new SwapService({
manager: MockManager,
eventBusService,
swapRepository: swapRepo,
paymentProviderService,
eventBusService,
-753
View File
@@ -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<Order>} 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
+858
View File
@@ -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<ClaimOrder> {
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<ClaimOrder> {
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<ClaimOrder>)
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<string, unknown>
no_notification?: boolean
} = {
metadata: {},
}
): Promise<ClaimOrder> {
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<ClaimOrder> {
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<ClaimOrder> {
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<ClaimOrder> {
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<ClaimOrder> {
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<ClaimOrder> = {
skip: 0,
take: 50,
order: { created_at: "DESC" },
}
): Promise<ClaimOrder[]> {
return await this.atomicPhase_(
async (transactionManager: EntityManager) => {
const claimRepo = transactionManager.getCustomRepository(
this.claimRepository_
)
const query = buildQuery<ClaimOrder>(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<ClaimOrder> = {}
): Promise<ClaimOrder> {
return await this.atomicPhase_(
async (transactionManager: EntityManager) => {
const claimRepo = transactionManager.getCustomRepository(
this.claimRepository_
)
const query = buildQuery<ClaimOrder>({ 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
}
)
}
}
+89
View File
@@ -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<string, unknown>
}
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
}