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:
Riqwan Thamir
2025-01-07 07:56:28 +01:00
committed by GitHub
parent 99a06102a2
commit 47594192b7
27 changed files with 1241 additions and 1666 deletions
@@ -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,
})
@@ -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)
}
)