feat(dashboard,core-flows,types,utils,medusa): Order cancelations will refund payments (#10667)
* feat(order, types): Add Credit Line to order module * chore: add action to inject credit lines * WIP * chore: add fixes + observe * chore: fix balances * chore: add canceled badge * chore: fix i18n schema * chore: remove redunddant query * chore: add changeset * chore: add credit lines for all cancel cases * chore: add accounting total * chore: address review & cleanup
This commit is contained in:
@@ -5,8 +5,10 @@ import {
|
||||
PaymentCollectionDTO,
|
||||
} from "@medusajs/framework/types"
|
||||
import {
|
||||
MathBN,
|
||||
MedusaError,
|
||||
OrderWorkflowEvents,
|
||||
PaymentCollectionStatus,
|
||||
deepFlatMap,
|
||||
} from "@medusajs/framework/utils"
|
||||
import {
|
||||
@@ -17,12 +19,16 @@ import {
|
||||
createWorkflow,
|
||||
parallelize,
|
||||
transform,
|
||||
when,
|
||||
} from "@medusajs/framework/workflows-sdk"
|
||||
import { emitEventStep, useRemoteQueryStep } from "../../common"
|
||||
import { emitEventStep, useQueryGraphStep } from "../../common"
|
||||
import { updatePaymentCollectionStep } from "../../payment-collection"
|
||||
import { cancelPaymentStep } from "../../payment/steps"
|
||||
import { deleteReservationsByLineItemsStep } from "../../reservation/steps"
|
||||
import { cancelOrdersStep } from "../steps/cancel-orders"
|
||||
import { throwIfOrderIsCancelled } from "../utils/order-validation"
|
||||
import { createOrderRefundCreditLinesWorkflow } from "./payments/create-order-refund-credit-lines"
|
||||
import { refundCapturedPaymentsWorkflow } from "./payments/refund-captured-payments"
|
||||
|
||||
/**
|
||||
* This step validates that an order can be canceled.
|
||||
@@ -42,28 +48,6 @@ export const cancelValidateOrder = createStep(
|
||||
|
||||
throwIfOrderIsCancelled({ order })
|
||||
|
||||
let refunds = 0
|
||||
let captures = 0
|
||||
|
||||
deepFlatMap(order_, "payment_collections.payments", ({ payments }) => {
|
||||
refunds += payments?.refunds?.length ?? 0
|
||||
captures += payments?.captures?.length ?? 0
|
||||
})
|
||||
|
||||
if (captures > 0) {
|
||||
throw new MedusaError(
|
||||
MedusaError.Types.NOT_ALLOWED,
|
||||
"Order with payment capture(s) cannot be canceled"
|
||||
)
|
||||
}
|
||||
|
||||
if (refunds > 0) {
|
||||
throw new MedusaError(
|
||||
MedusaError.Types.NOT_ALLOWED,
|
||||
"Order with payment refund(s) cannot be canceled"
|
||||
)
|
||||
}
|
||||
|
||||
const throwErrorIf = (
|
||||
arr: unknown[],
|
||||
pred: (obj: any) => boolean,
|
||||
@@ -90,42 +74,74 @@ export const cancelOrderWorkflowId = "cancel-order"
|
||||
export const cancelOrderWorkflow = createWorkflow(
|
||||
cancelOrderWorkflowId,
|
||||
(input: WorkflowData<OrderWorkflow.CancelOrderWorkflowInput>) => {
|
||||
const order: OrderDTO & { fulfillments: FulfillmentDTO[] } =
|
||||
useRemoteQueryStep({
|
||||
entry_point: "orders",
|
||||
fields: [
|
||||
"id",
|
||||
"status",
|
||||
"items.id",
|
||||
"fulfillments.canceled_at",
|
||||
"payment_collections.payments.id",
|
||||
"payment_collections.payments.refunds.id",
|
||||
"payment_collections.payments.captures.id",
|
||||
],
|
||||
variables: { id: input.order_id },
|
||||
list: false,
|
||||
throw_if_key_not_found: true,
|
||||
})
|
||||
const orderQuery = useQueryGraphStep({
|
||||
entity: "orders",
|
||||
fields: [
|
||||
"id",
|
||||
"status",
|
||||
"items.id",
|
||||
"fulfillments.canceled_at",
|
||||
"payment_collections.payments.id",
|
||||
"payment_collections.payments.amount",
|
||||
"payment_collections.payments.refunds.id",
|
||||
"payment_collections.payments.refunds.amount",
|
||||
"payment_collections.payments.captures.id",
|
||||
"payment_collections.payments.captures.amount",
|
||||
],
|
||||
filters: { id: input.order_id },
|
||||
options: { throwIfKeyNotFound: true },
|
||||
}).config({ name: "get-cart" })
|
||||
|
||||
const order = transform(
|
||||
{ orderQuery },
|
||||
({ orderQuery }) => orderQuery.data[0]
|
||||
)
|
||||
|
||||
cancelValidateOrder({ order, input })
|
||||
|
||||
const uncapturedPaymentIds = transform({ order }, ({ order }) => {
|
||||
const payments = deepFlatMap(
|
||||
order,
|
||||
"payment_collections.payments",
|
||||
({ payments }) => payments
|
||||
)
|
||||
|
||||
const uncapturedPayments = payments.filter(
|
||||
(payment) => payment.captures.length === 0
|
||||
)
|
||||
|
||||
return uncapturedPayments.map((payment) => payment.id)
|
||||
})
|
||||
|
||||
const creditLineAmount = transform({ order }, ({ order }) => {
|
||||
const payments = deepFlatMap(
|
||||
order,
|
||||
"payment_collections.payments",
|
||||
({ payments }) => payments
|
||||
)
|
||||
|
||||
return payments.reduce(
|
||||
(acc, payment) => MathBN.sum(acc, payment.amount),
|
||||
MathBN.convert(0)
|
||||
)
|
||||
})
|
||||
|
||||
const lineItemIds = transform({ order }, ({ order }) => {
|
||||
return order.items?.map((i) => i.id)
|
||||
})
|
||||
|
||||
const paymentIds = transform({ order }, ({ order }) => {
|
||||
return deepFlatMap(
|
||||
order,
|
||||
"payment_collections.payments",
|
||||
({ payments }) => {
|
||||
return payments?.id
|
||||
}
|
||||
)
|
||||
})
|
||||
|
||||
parallelize(
|
||||
createOrderRefundCreditLinesWorkflow.runAsStep({
|
||||
input: {
|
||||
order_id: order.id,
|
||||
amount: creditLineAmount,
|
||||
},
|
||||
}),
|
||||
deleteReservationsByLineItemsStep(lineItemIds),
|
||||
cancelPaymentStep({ paymentIds }),
|
||||
cancelPaymentStep({ paymentIds: uncapturedPaymentIds }),
|
||||
refundCapturedPaymentsWorkflow.runAsStep({
|
||||
input: { order_id: order.id },
|
||||
}),
|
||||
cancelOrdersStep({ orderIds: [order.id] }),
|
||||
emitEventStep({
|
||||
eventName: OrderWorkflowEvents.CANCELED,
|
||||
@@ -133,6 +149,19 @@ export const cancelOrderWorkflow = createWorkflow(
|
||||
})
|
||||
)
|
||||
|
||||
const paymentCollectionids = transform({ order }, ({ order }) =>
|
||||
order.payment_collections?.map((pc) => pc.id)
|
||||
)
|
||||
|
||||
when({ paymentCollectionids }, ({ paymentCollectionids }) => {
|
||||
return !!paymentCollectionids?.length
|
||||
}).then(() => {
|
||||
updatePaymentCollectionStep({
|
||||
selector: { id: paymentCollectionids },
|
||||
update: { status: PaymentCollectionStatus.CANCELED },
|
||||
})
|
||||
})
|
||||
|
||||
const orderCanceled = createHook("orderCanceled", {
|
||||
order,
|
||||
})
|
||||
|
||||
+113
@@ -0,0 +1,113 @@
|
||||
import { BigNumberInput, OrderDTO } from "@medusajs/framework/types"
|
||||
import {
|
||||
ChangeActionType,
|
||||
OrderChangeStatus,
|
||||
OrderChangeType,
|
||||
} from "@medusajs/framework/utils"
|
||||
import {
|
||||
WorkflowData,
|
||||
createStep,
|
||||
createWorkflow,
|
||||
transform,
|
||||
} from "@medusajs/framework/workflows-sdk"
|
||||
import { useQueryGraphStep } from "../../../common"
|
||||
import { confirmOrderChanges } from "../../steps/confirm-order-changes"
|
||||
import { createOrderChangeStep } from "../../steps/create-order-change"
|
||||
import { throwIfOrderIsCancelled } from "../../utils/order-validation"
|
||||
import { createOrderChangeActionsWorkflow } from "../create-order-change-actions"
|
||||
|
||||
/**
|
||||
* This step validates that an order refund credit line can be issued
|
||||
*/
|
||||
export const validateOrderRefundCreditLinesStep = createStep(
|
||||
"begin-order-edit-validation",
|
||||
async function ({ order }: { order: OrderDTO }) {
|
||||
throwIfOrderIsCancelled({ order })
|
||||
}
|
||||
)
|
||||
|
||||
export const createOrderRefundCreditLinesWorkflowId =
|
||||
"create-order-refund-credit-lines"
|
||||
/**
|
||||
* This workflow creates an order refund credit line
|
||||
*/
|
||||
export const createOrderRefundCreditLinesWorkflow = createWorkflow(
|
||||
createOrderRefundCreditLinesWorkflowId,
|
||||
function (
|
||||
input: WorkflowData<{
|
||||
order_id: string
|
||||
created_by?: string
|
||||
amount: BigNumberInput
|
||||
}>
|
||||
) {
|
||||
const orderQuery = useQueryGraphStep({
|
||||
entity: "orders",
|
||||
fields: ["id", "status", "summary", "payment_collections.id"],
|
||||
filters: { id: input.order_id },
|
||||
options: { throwIfKeyNotFound: true },
|
||||
}).config({ name: "get-order" })
|
||||
|
||||
const order = transform(
|
||||
{ orderQuery },
|
||||
({ orderQuery }) => orderQuery.data[0]
|
||||
)
|
||||
|
||||
validateOrderRefundCreditLinesStep({ order })
|
||||
|
||||
const orderChangeInput = transform({ input }, ({ input }) => ({
|
||||
change_type: OrderChangeType.CREDIT_LINE,
|
||||
order_id: input.order_id,
|
||||
created_by: input.created_by,
|
||||
}))
|
||||
|
||||
const createdOrderChange = createOrderChangeStep(orderChangeInput)
|
||||
|
||||
const orderChangeActionInput = transform(
|
||||
{ order, orderChange: createdOrderChange, input },
|
||||
({ order, orderChange, input }) => ({
|
||||
order_change_id: orderChange.id,
|
||||
order_id: order.id,
|
||||
version: orderChange.version,
|
||||
action: ChangeActionType.CREDIT_LINE_ADD,
|
||||
reference: "payment_collection",
|
||||
reference_id: order.payment_collections[0]?.id,
|
||||
amount: input.amount,
|
||||
})
|
||||
)
|
||||
|
||||
createOrderChangeActionsWorkflow.runAsStep({
|
||||
input: [orderChangeActionInput],
|
||||
})
|
||||
|
||||
const orderChangeQuery = useQueryGraphStep({
|
||||
entity: "order_change",
|
||||
fields: [
|
||||
"id",
|
||||
"status",
|
||||
"change_type",
|
||||
"actions.id",
|
||||
"actions.order_id",
|
||||
"actions.action",
|
||||
"actions.details",
|
||||
"actions.reference",
|
||||
"actions.reference_id",
|
||||
"actions.internal_note",
|
||||
],
|
||||
filters: {
|
||||
order_id: input.order_id,
|
||||
status: [OrderChangeStatus.PENDING],
|
||||
},
|
||||
options: { throwIfKeyNotFound: true },
|
||||
}).config({ name: "order-change-query" })
|
||||
|
||||
const orderChange = transform(
|
||||
{ orderChangeQuery },
|
||||
({ orderChangeQuery }) => orderChangeQuery.data[0]
|
||||
)
|
||||
|
||||
confirmOrderChanges({
|
||||
changes: [orderChange],
|
||||
orderId: order.id,
|
||||
})
|
||||
}
|
||||
)
|
||||
@@ -0,0 +1,98 @@
|
||||
import { PaymentDTO } from "@medusajs/framework/types"
|
||||
import { deepFlatMap, MathBN } from "@medusajs/framework/utils"
|
||||
import {
|
||||
createWorkflow,
|
||||
transform,
|
||||
when,
|
||||
WorkflowData,
|
||||
} from "@medusajs/framework/workflows-sdk"
|
||||
import { useQueryGraphStep } from "../../../common"
|
||||
import { refundPaymentsWorkflow } from "../../../payment"
|
||||
|
||||
export const refundCapturedPaymentsWorkflowId =
|
||||
"refund-captured-payments-workflow"
|
||||
/**
|
||||
* This workflow refunds a payment.
|
||||
*/
|
||||
export const refundCapturedPaymentsWorkflow = createWorkflow(
|
||||
refundCapturedPaymentsWorkflowId,
|
||||
(
|
||||
input: WorkflowData<{
|
||||
order_id: string
|
||||
created_by?: string
|
||||
}>
|
||||
) => {
|
||||
const orderQuery = useQueryGraphStep({
|
||||
entity: "orders",
|
||||
fields: [
|
||||
"id",
|
||||
"status",
|
||||
"summary",
|
||||
"payment_collections.payments.id",
|
||||
"payment_collections.payments.amount",
|
||||
"payment_collections.payments.refunds.id",
|
||||
"payment_collections.payments.refunds.amount",
|
||||
"payment_collections.payments.captures.id",
|
||||
"payment_collections.payments.captures.amount",
|
||||
],
|
||||
filters: { id: input.order_id },
|
||||
options: { throwIfKeyNotFound: true },
|
||||
}).config({ name: "get-order" })
|
||||
|
||||
const order = transform(
|
||||
{ orderQuery },
|
||||
({ orderQuery }) => orderQuery.data[0]
|
||||
)
|
||||
|
||||
const refundPaymentsData = transform(
|
||||
{ order, input },
|
||||
({ order, input }) => {
|
||||
const payments: PaymentDTO[] = deepFlatMap(
|
||||
order,
|
||||
"payment_collections.payments",
|
||||
({ payments }) => payments
|
||||
)
|
||||
|
||||
const capturedPayments = payments.filter(
|
||||
(payment) => payment.captures?.length
|
||||
)
|
||||
|
||||
return capturedPayments
|
||||
.map((payment) => {
|
||||
const capturedAmount = (payment.captures || []).reduce(
|
||||
(acc, capture) => MathBN.sum(acc, capture.amount),
|
||||
MathBN.convert(0)
|
||||
)
|
||||
const refundedAmount = (payment.refunds || []).reduce(
|
||||
(acc, refund) => MathBN.sum(acc, refund.amount),
|
||||
MathBN.convert(0)
|
||||
)
|
||||
|
||||
const amountToRefund = MathBN.sub(capturedAmount, refundedAmount)
|
||||
|
||||
return {
|
||||
payment_id: payment.id,
|
||||
created_by: input.created_by,
|
||||
amount: amountToRefund,
|
||||
}
|
||||
})
|
||||
.filter((payment) => MathBN.gt(payment.amount, 0))
|
||||
}
|
||||
)
|
||||
|
||||
const totalCaptured = transform(
|
||||
{ refundPaymentsData },
|
||||
({ refundPaymentsData }) =>
|
||||
refundPaymentsData.reduce(
|
||||
(acc, refundPayment) => MathBN.sum(acc, refundPayment.amount),
|
||||
MathBN.convert(0)
|
||||
)
|
||||
)
|
||||
|
||||
when({ totalCaptured }, ({ totalCaptured }) => {
|
||||
return !!MathBN.gt(totalCaptured, 0)
|
||||
}).then(() => {
|
||||
refundPaymentsWorkflow.runAsStep({ input: refundPaymentsData })
|
||||
})
|
||||
}
|
||||
)
|
||||
@@ -27,6 +27,7 @@ export const cancelPaymentStep = createStep(
|
||||
: [input.paymentIds]
|
||||
|
||||
const promises: Promise<any>[] = []
|
||||
|
||||
for (const id of paymentIds) {
|
||||
promises.push(
|
||||
paymentModule.cancelPayment(id).catch((e) => {
|
||||
@@ -36,6 +37,7 @@ export const cancelPaymentStep = createStep(
|
||||
})
|
||||
)
|
||||
}
|
||||
|
||||
await promiseAll(promises)
|
||||
}
|
||||
)
|
||||
|
||||
@@ -2,3 +2,4 @@ export * from "./authorize-payment-session"
|
||||
export * from "./cancel-payment"
|
||||
export * from "./capture-payment"
|
||||
export * from "./refund-payment"
|
||||
export * from "./refund-payments"
|
||||
|
||||
@@ -0,0 +1,52 @@
|
||||
import {
|
||||
BigNumberInput,
|
||||
IPaymentModuleService,
|
||||
Logger,
|
||||
PaymentDTO,
|
||||
} from "@medusajs/framework/types"
|
||||
import {
|
||||
ContainerRegistrationKeys,
|
||||
isObject,
|
||||
Modules,
|
||||
promiseAll,
|
||||
} from "@medusajs/framework/utils"
|
||||
import { createStep, StepResponse } from "@medusajs/framework/workflows-sdk"
|
||||
|
||||
export const refundPaymentsStepId = "refund-payments-step"
|
||||
/**
|
||||
* This step refunds one or more payments.
|
||||
*/
|
||||
export const refundPaymentsStep = createStep(
|
||||
refundPaymentsStepId,
|
||||
async (
|
||||
input: {
|
||||
payment_id: string
|
||||
amount: BigNumberInput
|
||||
created_by?: string
|
||||
}[],
|
||||
{ container }
|
||||
) => {
|
||||
const logger = container.resolve<Logger>(ContainerRegistrationKeys.LOGGER)
|
||||
const paymentModule = container.resolve<IPaymentModuleService>(
|
||||
Modules.PAYMENT
|
||||
)
|
||||
|
||||
const promises: Promise<PaymentDTO | void>[] = []
|
||||
|
||||
for (const refundInput of input) {
|
||||
promises.push(
|
||||
paymentModule.refundPayment(refundInput).catch((e) => {
|
||||
logger.error(
|
||||
`Error was thrown trying to cancel payment - ${refundInput.payment_id} - ${e}`
|
||||
)
|
||||
})
|
||||
)
|
||||
}
|
||||
|
||||
const successfulRefunds = (await promiseAll(promises)).filter((payment) =>
|
||||
isObject(payment)
|
||||
)
|
||||
|
||||
return new StepResponse(successfulRefunds)
|
||||
}
|
||||
)
|
||||
@@ -1,3 +1,4 @@
|
||||
export * from "./capture-payment"
|
||||
export * from "./process-payment"
|
||||
export * from "./refund-payment"
|
||||
export * from "./refund-payments"
|
||||
|
||||
@@ -0,0 +1,129 @@
|
||||
import { BigNumberInput, PaymentDTO } from "@medusajs/framework/types"
|
||||
import { MathBN, MedusaError } from "@medusajs/framework/utils"
|
||||
import {
|
||||
createStep,
|
||||
createWorkflow,
|
||||
transform,
|
||||
WorkflowData,
|
||||
WorkflowResponse,
|
||||
} from "@medusajs/framework/workflows-sdk"
|
||||
import { useQueryGraphStep } from "../../common"
|
||||
import { addOrderTransactionStep } from "../../order"
|
||||
import { refundPaymentsStep } from "../steps/refund-payments"
|
||||
|
||||
type RefundPaymentsInput = {
|
||||
payment_id: string
|
||||
amount: BigNumberInput
|
||||
created_by?: string
|
||||
}
|
||||
|
||||
/**
|
||||
* This step validates that the refund is valid for the payment
|
||||
*/
|
||||
export const validatePaymentsRefundStep = createStep(
|
||||
"validate-payments-refund-step",
|
||||
async function ({
|
||||
payments,
|
||||
input,
|
||||
}: {
|
||||
payments: PaymentDTO[]
|
||||
input: RefundPaymentsInput[]
|
||||
}) {
|
||||
const paymentIdAmountMap = new Map<string, BigNumberInput>(
|
||||
input.map(({ payment_id, amount }) => [payment_id, amount])
|
||||
)
|
||||
|
||||
for (const payment of payments) {
|
||||
const capturedAmount = (payment.captures || []).reduce(
|
||||
(acc, capture) => MathBN.sum(acc, capture.amount),
|
||||
MathBN.convert(0)
|
||||
)
|
||||
|
||||
const refundedAmount = (payment.refunds || []).reduce(
|
||||
(acc, capture) => MathBN.sum(acc, capture.amount),
|
||||
MathBN.convert(0)
|
||||
)
|
||||
|
||||
const refundableAmount = MathBN.sub(capturedAmount, refundedAmount)
|
||||
const amountToRefund = paymentIdAmountMap.get(payment.id)!
|
||||
|
||||
if (MathBN.gt(amountToRefund, refundableAmount)) {
|
||||
throw new MedusaError(
|
||||
MedusaError.Types.INVALID_DATA,
|
||||
`Payment with id ${payment.id} is trying to refund amount greater than the refundable amount`
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
export const refundPaymentsWorkflowId = "refund-payments-workflow"
|
||||
/**
|
||||
* This workflow refunds a payment.
|
||||
*/
|
||||
export const refundPaymentsWorkflow = createWorkflow(
|
||||
refundPaymentsWorkflowId,
|
||||
(input: WorkflowData<RefundPaymentsInput[]>) => {
|
||||
const paymentIds = transform({ input }, ({ input }) =>
|
||||
input.map((paymentInput) => paymentInput.payment_id)
|
||||
)
|
||||
|
||||
const paymentsQuery = useQueryGraphStep({
|
||||
entity: "payments",
|
||||
fields: [
|
||||
"id",
|
||||
"currency_code",
|
||||
"refunds.id",
|
||||
"refunds.amount",
|
||||
"captures.id",
|
||||
"captures.amount",
|
||||
"payment_collection.order.id",
|
||||
"payment_collection.order.currency_code",
|
||||
],
|
||||
filters: { id: paymentIds },
|
||||
options: { throwIfKeyNotFound: true },
|
||||
}).config({ name: "get-cart" })
|
||||
|
||||
const payments = transform(
|
||||
{ paymentsQuery },
|
||||
({ paymentsQuery }) => paymentsQuery.data
|
||||
)
|
||||
|
||||
validatePaymentsRefundStep({ payments, input })
|
||||
|
||||
const refundedPayments = refundPaymentsStep(input)
|
||||
|
||||
const orderTransactionData = transform(
|
||||
{ payments, input },
|
||||
({ payments, input }) => {
|
||||
const paymentsMap: Record<
|
||||
string,
|
||||
PaymentDTO & {
|
||||
payment_collection: { order: { id: string; currency_code: string } }
|
||||
}
|
||||
> = {}
|
||||
|
||||
for (const payment of payments) {
|
||||
paymentsMap[payment.id] = payment
|
||||
}
|
||||
|
||||
return input.map((paymentInput) => {
|
||||
const payment = paymentsMap[paymentInput.payment_id]!
|
||||
const order = payment.payment_collection.order
|
||||
|
||||
return {
|
||||
order_id: order.id,
|
||||
amount: MathBN.mult(paymentInput.amount, -1),
|
||||
currency_code: payment.currency_code,
|
||||
reference_id: payment.id,
|
||||
reference: "refund",
|
||||
}
|
||||
})
|
||||
}
|
||||
)
|
||||
|
||||
addOrderTransactionStep(orderTransactionData)
|
||||
|
||||
return new WorkflowResponse(refundedPayments)
|
||||
}
|
||||
)
|
||||
Reference in New Issue
Block a user