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:
10
.changeset/tall-camels-dance.md
Normal file
10
.changeset/tall-camels-dance.md
Normal file
@@ -0,0 +1,10 @@
|
||||
---
|
||||
"@medusajs/dashboard": patch
|
||||
"@medusajs/core-flows": patch
|
||||
"@medusajs/types": patch
|
||||
"@medusajs/utils": patch
|
||||
"@medusajs/medusa": patch
|
||||
"@medusajs/order": patch
|
||||
---
|
||||
|
||||
feat(dashboard,core-flows,types,utils,medusa,order): Order cancelations will refund payments
|
||||
@@ -343,6 +343,199 @@ medusaIntegrationTestRunner({
|
||||
})
|
||||
})
|
||||
|
||||
describe("POST /orders/:id/cancel", () => {
|
||||
beforeEach(async () => {
|
||||
seeder = await createOrderSeeder({
|
||||
api,
|
||||
container: getContainer(),
|
||||
})
|
||||
order = seeder.order
|
||||
|
||||
order = (await api.get(`/admin/orders/${order.id}`, adminHeaders)).data
|
||||
.order
|
||||
})
|
||||
|
||||
it("should successfully cancel an order and its authorized but not captured payments", async () => {
|
||||
const response = await api.post(
|
||||
`/admin/orders/${order.id}/cancel`,
|
||||
{},
|
||||
adminHeaders
|
||||
)
|
||||
|
||||
expect(response.status).toBe(200)
|
||||
expect(response.data.order).toEqual(
|
||||
expect.objectContaining({
|
||||
id: order.id,
|
||||
status: "canceled",
|
||||
|
||||
summary: expect.objectContaining({
|
||||
credit_line_total: 106,
|
||||
current_order_total: 0,
|
||||
accounting_total: 0,
|
||||
}),
|
||||
|
||||
payment_collections: [
|
||||
expect.objectContaining({
|
||||
status: "canceled",
|
||||
captured_amount: 0,
|
||||
refunded_amount: 0,
|
||||
amount: 106,
|
||||
payments: [
|
||||
expect.objectContaining({
|
||||
canceled_at: expect.any(String),
|
||||
refunds: [],
|
||||
captures: [],
|
||||
}),
|
||||
],
|
||||
}),
|
||||
],
|
||||
})
|
||||
)
|
||||
})
|
||||
|
||||
it("should successfully cancel an order with a captured payment", async () => {
|
||||
const payment = order.payment_collections[0].payments[0]
|
||||
|
||||
const paymentResponse = await api.post(
|
||||
`/admin/payments/${payment.id}/capture`,
|
||||
undefined,
|
||||
adminHeaders
|
||||
)
|
||||
|
||||
expect(paymentResponse.data.payment).toEqual(
|
||||
expect.objectContaining({
|
||||
id: payment.id,
|
||||
captured_at: expect.any(String),
|
||||
captures: [
|
||||
expect.objectContaining({
|
||||
id: expect.any(String),
|
||||
amount: 106,
|
||||
}),
|
||||
],
|
||||
refunds: [],
|
||||
amount: 106,
|
||||
})
|
||||
)
|
||||
|
||||
const response = await api.post(
|
||||
`/admin/orders/${order.id}/cancel`,
|
||||
{},
|
||||
adminHeaders
|
||||
)
|
||||
|
||||
expect(response.status).toBe(200)
|
||||
expect(response.data.order).toEqual(
|
||||
expect.objectContaining({
|
||||
id: order.id,
|
||||
status: "canceled",
|
||||
|
||||
summary: expect.objectContaining({
|
||||
credit_line_total: 106,
|
||||
current_order_total: 0,
|
||||
accounting_total: 0,
|
||||
}),
|
||||
|
||||
payment_collections: [
|
||||
expect.objectContaining({
|
||||
status: "canceled",
|
||||
captured_amount: 106,
|
||||
refunded_amount: 106,
|
||||
amount: 106,
|
||||
payments: [
|
||||
expect.objectContaining({
|
||||
// canceled_at: expect.any(String),
|
||||
refunds: [
|
||||
expect.objectContaining({
|
||||
id: expect.any(String),
|
||||
amount: 106,
|
||||
}),
|
||||
],
|
||||
captures: [
|
||||
expect.objectContaining({
|
||||
id: expect.any(String),
|
||||
amount: 106,
|
||||
}),
|
||||
],
|
||||
}),
|
||||
],
|
||||
}),
|
||||
],
|
||||
})
|
||||
)
|
||||
})
|
||||
|
||||
it("should successfully cancel an order with a partially captured payment", async () => {
|
||||
const payment = order.payment_collections[0].payments[0]
|
||||
|
||||
const paymentResponse = await api.post(
|
||||
`/admin/payments/${payment.id}/capture`,
|
||||
{ amount: 50 },
|
||||
adminHeaders
|
||||
)
|
||||
|
||||
expect(paymentResponse.data.payment).toEqual(
|
||||
expect.objectContaining({
|
||||
id: payment.id,
|
||||
captured_at: null,
|
||||
captures: [
|
||||
expect.objectContaining({
|
||||
id: expect.any(String),
|
||||
amount: 50,
|
||||
}),
|
||||
],
|
||||
refunds: [],
|
||||
amount: 106,
|
||||
})
|
||||
)
|
||||
|
||||
const response = await api.post(
|
||||
`/admin/orders/${order.id}/cancel`,
|
||||
{},
|
||||
adminHeaders
|
||||
)
|
||||
|
||||
expect(response.status).toBe(200)
|
||||
expect(response.data.order).toEqual(
|
||||
expect.objectContaining({
|
||||
id: order.id,
|
||||
status: "canceled",
|
||||
|
||||
summary: expect.objectContaining({
|
||||
credit_line_total: 106,
|
||||
current_order_total: 0,
|
||||
accounting_total: 0,
|
||||
}),
|
||||
|
||||
payment_collections: [
|
||||
expect.objectContaining({
|
||||
status: "canceled",
|
||||
captured_amount: 50,
|
||||
refunded_amount: 50,
|
||||
amount: 106,
|
||||
payments: [
|
||||
expect.objectContaining({
|
||||
// canceled_at: expect.any(String),
|
||||
refunds: [
|
||||
expect.objectContaining({
|
||||
id: expect.any(String),
|
||||
amount: 50,
|
||||
}),
|
||||
],
|
||||
captures: [
|
||||
expect.objectContaining({
|
||||
id: expect.any(String),
|
||||
amount: 50,
|
||||
}),
|
||||
],
|
||||
}),
|
||||
],
|
||||
}),
|
||||
],
|
||||
})
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
describe("POST /orders/:id/fulfillments", () => {
|
||||
beforeEach(async () => {
|
||||
const stockChannelOverride = (
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -916,6 +916,15 @@
|
||||
"list": {
|
||||
"noRecordsMessage": "Your orders will show up here."
|
||||
},
|
||||
"status": {
|
||||
"not_paid": "Not paid",
|
||||
"pending": "Pending",
|
||||
"completed": "Completed",
|
||||
"draft": "Draft",
|
||||
"archived": "Archived",
|
||||
"canceled": "Canceled",
|
||||
"requires_action": "Requires action"
|
||||
},
|
||||
"summary": {
|
||||
"requestReturn": "Request return",
|
||||
"allocateItems": "Allocate items",
|
||||
|
||||
@@ -1,5 +1,16 @@
|
||||
import { TFunction } from "i18next"
|
||||
|
||||
export const getCanceledOrderStatus = (
|
||||
t: TFunction<"translation">,
|
||||
status: string
|
||||
) => {
|
||||
if (status === "canceled") {
|
||||
return { label: t("orders.status.canceled"), color: "red" }
|
||||
}
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
export const getOrderPaymentStatus = (
|
||||
t: TFunction<"translation">,
|
||||
status: string
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { XCircle } from "@medusajs/icons"
|
||||
import { HttpTypes } from "@medusajs/types"
|
||||
import {
|
||||
Container,
|
||||
Copy,
|
||||
@@ -9,13 +10,14 @@ import {
|
||||
} from "@medusajs/ui"
|
||||
import { format } from "date-fns"
|
||||
import { useTranslation } from "react-i18next"
|
||||
import { isPresent } from "../../../../../../../../core/utils/src/common/is-present"
|
||||
import { ActionMenu } from "../../../../../components/common/action-menu"
|
||||
import { useCancelOrder } from "../../../../../hooks/api/orders"
|
||||
import {
|
||||
getCanceledOrderStatus,
|
||||
getOrderFulfillmentStatus,
|
||||
getOrderPaymentStatus,
|
||||
} from "../../../../../lib/order-helpers"
|
||||
import { HttpTypes } from "@medusajs/types"
|
||||
|
||||
type OrderGeneralSectionProps = {
|
||||
order: HttpTypes.AdminOrder
|
||||
@@ -60,6 +62,7 @@ export const OrderGeneralSection = ({ order }: OrderGeneralSectionProps) => {
|
||||
</div>
|
||||
<div className="flex items-center gap-x-4">
|
||||
<div className="flex items-center gap-x-1.5">
|
||||
<OrderBadge order={order} />
|
||||
<PaymentBadge order={order} />
|
||||
<FulfillmentBadge order={order} />
|
||||
</div>
|
||||
@@ -108,3 +111,18 @@ const PaymentBadge = ({ order }: { order: HttpTypes.AdminOrder }) => {
|
||||
</StatusBadge>
|
||||
)
|
||||
}
|
||||
|
||||
const OrderBadge = ({ order }: { order: HttpTypes.AdminOrder }) => {
|
||||
const { t } = useTranslation()
|
||||
const orderStatus = getCanceledOrderStatus(t, order.status)
|
||||
|
||||
if (!isPresent(orderStatus)) {
|
||||
return
|
||||
}
|
||||
|
||||
return (
|
||||
<StatusBadge color={orderStatus.color} className="text-nowrap">
|
||||
{orderStatus.label}
|
||||
</StatusBadge>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,5 +1,9 @@
|
||||
import { ArrowDownRightMini, DocumentText, XCircle } from "@medusajs/icons"
|
||||
import { AdminPaymentCollection, HttpTypes } from "@medusajs/types"
|
||||
import {
|
||||
AdminPayment,
|
||||
AdminPaymentCollection,
|
||||
HttpTypes,
|
||||
} from "@medusajs/types"
|
||||
import {
|
||||
Badge,
|
||||
Button,
|
||||
@@ -14,6 +18,7 @@ import {
|
||||
import { format } from "date-fns"
|
||||
import { Trans, useTranslation } from "react-i18next"
|
||||
import { ActionMenu } from "../../../../../components/common/action-menu"
|
||||
import DisplayId from "../../../../../components/common/display-id/display-id"
|
||||
import { useCapturePayment } from "../../../../../hooks/api"
|
||||
import { formatCurrency } from "../../../../../lib/format-currency"
|
||||
import {
|
||||
@@ -22,7 +27,6 @@ import {
|
||||
} from "../../../../../lib/money-amount-helpers"
|
||||
import { getOrderPaymentStatus } from "../../../../../lib/order-helpers"
|
||||
import { getTotalCaptured, getTotalPending } from "../../../../../lib/payment"
|
||||
import DisplayId from "../../../../../components/common/display-id/display-id"
|
||||
|
||||
type OrderPaymentSectionProps = {
|
||||
order: HttpTypes.AdminOrder
|
||||
@@ -173,9 +177,20 @@ const Payment = ({
|
||||
)
|
||||
}
|
||||
|
||||
const [status, color] = (
|
||||
payment.captured_at ? ["Captured", "green"] : ["Pending", "orange"]
|
||||
) as [string, "green" | "orange"]
|
||||
const getPaymentStatusAttributes = (payment: AdminPayment) => {
|
||||
if (payment.canceled_at) {
|
||||
return ["Canceled", "red"]
|
||||
} else if (payment.captured_at) {
|
||||
return ["Captured", "green"]
|
||||
} else {
|
||||
return ["Pending", "orange"]
|
||||
}
|
||||
}
|
||||
|
||||
const [status, color] = getPaymentStatusAttributes(payment) as [
|
||||
string,
|
||||
"green" | "orange" | "red",
|
||||
]
|
||||
|
||||
const showCapture =
|
||||
payment.captured_at === null && payment.canceled_at === null
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
)
|
||||
@@ -773,6 +773,10 @@ export interface BaseOrder {
|
||||
* The order's display ID.
|
||||
*/
|
||||
display_id?: number
|
||||
/**
|
||||
* The order's status.
|
||||
*/
|
||||
status: string
|
||||
/**
|
||||
* The order's shipping address.
|
||||
*/
|
||||
|
||||
@@ -127,6 +127,18 @@ export type OrderSummaryDTO = {
|
||||
* @ignore
|
||||
*/
|
||||
raw_pending_difference: BigNumberRawValue
|
||||
|
||||
/**
|
||||
* The sum difference of all actions
|
||||
*/
|
||||
difference_sum: BigNumberValue
|
||||
|
||||
/**
|
||||
* The raw sum difference of all actions
|
||||
*
|
||||
* @ignore
|
||||
*/
|
||||
raw_difference_sum: BigNumberRawValue
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -18,6 +18,7 @@ type OrderChangeType =
|
||||
| "edit"
|
||||
| "transfer"
|
||||
| "update_order"
|
||||
| "credit_line"
|
||||
|
||||
/** ADDRESS START */
|
||||
/**
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { BigNumberInput } from "../totals"
|
||||
import { PaymentCollectionStatus } from "./common"
|
||||
import { PaymentProviderContext } from "./provider"
|
||||
|
||||
/**
|
||||
@@ -77,10 +78,15 @@ export interface PaymentCollectionUpdatableFields {
|
||||
region_id?: string
|
||||
|
||||
/**
|
||||
* {The ISO 3 character currency code of the payment collection.
|
||||
* The ISO 3 character currency code of the payment collection.
|
||||
*/
|
||||
currency_code?: string
|
||||
|
||||
/**
|
||||
* The status of the payment collection
|
||||
*/
|
||||
status?: PaymentCollectionStatus
|
||||
|
||||
/**
|
||||
* The amount of the payment collection.
|
||||
*/
|
||||
|
||||
@@ -27,4 +27,5 @@ export enum OrderChangeType {
|
||||
EXCHANGE = "exchange",
|
||||
CLAIM = "claim",
|
||||
EDIT = "edit",
|
||||
CREDIT_LINE = "credit_line",
|
||||
}
|
||||
|
||||
@@ -46,6 +46,7 @@ export const defaultAdminRetrieveOrderFields = [
|
||||
"*payment_collections",
|
||||
"*payment_collections.payments",
|
||||
"*payment_collections.payments.refunds",
|
||||
"*payment_collections.payments.captures",
|
||||
]
|
||||
|
||||
export const defaultAdminRetrieveOrderChangesFields = [
|
||||
|
||||
@@ -51,22 +51,43 @@ describe("Action: Credit Line Add", function () {
|
||||
"credit_line_total": 0
|
||||
}
|
||||
|
||||
Upon adding a credit line, the order total and the pending difference will increase making it possible for the merchant
|
||||
to request the customer for a payment for an arbitrary reason, or prepare the order balance sheet to then allow
|
||||
the merchant to provide a refund.
|
||||
Upon adding a credit line, the current order total will decrease with the difference_sum going in
|
||||
the negatives making it possible for the merchant to balance the order to then enable a refund.
|
||||
|
||||
{
|
||||
"transaction_total": 0,
|
||||
"original_order_total": 30,
|
||||
"current_order_total": 60,
|
||||
"pending_difference": 60,
|
||||
"difference_sum": 30,
|
||||
"pending_difference": 0,
|
||||
"difference_sum": -30,
|
||||
"paid_total": 0,
|
||||
"refunded_total": 0,
|
||||
"credit_line_total": 30
|
||||
}
|
||||
*/
|
||||
it("should add credit lines", function () {
|
||||
const changesWithoutActions = calculateOrderChange({
|
||||
order: originalOrder,
|
||||
actions: [],
|
||||
options: { addActionReferenceToObject: true },
|
||||
})
|
||||
|
||||
const changesWithoutActionsJSON = JSON.parse(
|
||||
JSON.stringify(changesWithoutActions.summary)
|
||||
)
|
||||
|
||||
expect(changesWithoutActionsJSON).toEqual({
|
||||
transaction_total: 0,
|
||||
original_order_total: 30,
|
||||
current_order_total: 30,
|
||||
pending_difference: 30,
|
||||
difference_sum: 0,
|
||||
paid_total: 0,
|
||||
refunded_total: 0,
|
||||
credit_line_total: 0,
|
||||
accounting_total: 30,
|
||||
})
|
||||
|
||||
const actions = [
|
||||
{
|
||||
action: ChangeActionType.CREDIT_LINE_ADD,
|
||||
@@ -87,12 +108,13 @@ describe("Action: Credit Line Add", function () {
|
||||
expect(sumToJSON).toEqual({
|
||||
transaction_total: 0,
|
||||
original_order_total: 30,
|
||||
current_order_total: 60,
|
||||
pending_difference: 60,
|
||||
difference_sum: 30,
|
||||
current_order_total: 0,
|
||||
pending_difference: 0,
|
||||
difference_sum: 0,
|
||||
paid_total: 0,
|
||||
refunded_total: 0,
|
||||
credit_line_total: 30,
|
||||
accounting_total: 0,
|
||||
})
|
||||
|
||||
originalOrder.credit_lines.push({
|
||||
@@ -123,12 +145,13 @@ describe("Action: Credit Line Add", function () {
|
||||
expect(sumToJSONSecond).toEqual({
|
||||
transaction_total: 0,
|
||||
original_order_total: 30,
|
||||
current_order_total: 70,
|
||||
pending_difference: 70,
|
||||
difference_sum: 30,
|
||||
current_order_total: -10,
|
||||
pending_difference: -10,
|
||||
difference_sum: 0,
|
||||
paid_total: 0,
|
||||
refunded_total: 0,
|
||||
credit_line_total: 40,
|
||||
accounting_total: -10,
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -123,6 +123,7 @@ describe("Order Exchange - Actions", function () {
|
||||
paid_total: 0,
|
||||
refunded_total: 0,
|
||||
credit_line_total: 0,
|
||||
accounting_total: 312.5,
|
||||
})
|
||||
|
||||
const toJson = JSON.parse(JSON.stringify(changes.order.items))
|
||||
|
||||
@@ -3059,6 +3059,7 @@ export default class OrderModuleService<
|
||||
transformPropertiesToBigNumber(trxs)
|
||||
|
||||
const op = isRemoved ? MathBN.sub : MathBN.add
|
||||
|
||||
for (const trx of trxs) {
|
||||
if (MathBN.gt(trx.amount, 0)) {
|
||||
summary.totals.paid_total = new BigNumber(
|
||||
|
||||
@@ -84,6 +84,7 @@ export interface OrderSummaryCalculated {
|
||||
paid_total: BigNumberInput
|
||||
refunded_total: BigNumberInput
|
||||
credit_line_total: BigNumberInput
|
||||
accounting_total: BigNumberInput
|
||||
}
|
||||
|
||||
export interface OrderTransaction {
|
||||
|
||||
@@ -11,7 +11,9 @@ OrderChangeProcessing.registerActionType(ChangeActionType.CREDIT_LINE_ADD, {
|
||||
existing = {
|
||||
id: action.reference_id!,
|
||||
order_id: currentOrder.id,
|
||||
amount: action.amount as number,
|
||||
amount: action.amount!,
|
||||
reference: action.reference,
|
||||
reference_id: action.reference_id,
|
||||
}
|
||||
|
||||
creditLines.push(existing)
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { BigNumberInput, OrderSummaryDTO } from "@medusajs/framework/types"
|
||||
import {
|
||||
BigNumber,
|
||||
ChangeActionType,
|
||||
MathBN,
|
||||
isPresent,
|
||||
transformPropertiesToBigNumber,
|
||||
@@ -63,8 +64,6 @@ export class OrderChangeProcessing {
|
||||
MathBN.convert(0)
|
||||
)
|
||||
|
||||
const currentOrderTotal = MathBN.add(this.order.total ?? 0, creditLineTotal)
|
||||
|
||||
for (const tr of transactions) {
|
||||
if (MathBN.lt(tr.amount, 0)) {
|
||||
refunded = MathBN.add(refunded, MathBN.abs(tr.amount))
|
||||
@@ -79,12 +78,13 @@ export class OrderChangeProcessing {
|
||||
this.summary = {
|
||||
pending_difference: 0,
|
||||
difference_sum: 0,
|
||||
current_order_total: currentOrderTotal,
|
||||
current_order_total: this.order.total ?? 0,
|
||||
original_order_total: this.order.total ?? 0,
|
||||
transaction_total: transactionTotal,
|
||||
paid_total: paid,
|
||||
refunded_total: refunded,
|
||||
credit_line_total: creditLineTotal,
|
||||
accounting_total: MathBN.sub(this.order.total ?? 0, creditLineTotal),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -102,6 +102,11 @@ export class OrderChangeProcessing {
|
||||
}
|
||||
|
||||
public processActions() {
|
||||
let creditLineTotal = (this.order.credit_lines || []).reduce(
|
||||
(acc, creditLine) => MathBN.add(acc, creditLine.amount),
|
||||
MathBN.convert(0)
|
||||
)
|
||||
|
||||
for (const action of this.actions) {
|
||||
this.processAction_(action)
|
||||
}
|
||||
@@ -128,30 +133,37 @@ export class OrderChangeProcessing {
|
||||
)
|
||||
}
|
||||
|
||||
if (!this.isEventDone(action) && !action.change_id) {
|
||||
summary.difference_sum = MathBN.add(summary.difference_sum, amount)
|
||||
if (action.action === ChangeActionType.CREDIT_LINE_ADD) {
|
||||
creditLineTotal = MathBN.add(creditLineTotal, amount)
|
||||
} else {
|
||||
if (!this.isEventDone(action) && !action.change_id) {
|
||||
summary.difference_sum = MathBN.add(summary.difference_sum, amount)
|
||||
}
|
||||
|
||||
summary.current_order_total = MathBN.add(
|
||||
summary.current_order_total,
|
||||
amount
|
||||
)
|
||||
}
|
||||
|
||||
const creditLineTotal = (this.order.credit_lines || []).reduce(
|
||||
(acc, creditLine) => MathBN.add(acc, creditLine.amount),
|
||||
MathBN.convert(0)
|
||||
)
|
||||
|
||||
summary.credit_line_total = creditLineTotal
|
||||
summary.current_order_total = MathBN.add(
|
||||
summary.current_order_total,
|
||||
amount
|
||||
)
|
||||
}
|
||||
|
||||
const groupSum = MathBN.add(...Object.values(this.groupTotal))
|
||||
|
||||
summary.difference_sum = MathBN.add(summary.difference_sum, groupSum)
|
||||
summary.credit_line_total = creditLineTotal
|
||||
summary.accounting_total = MathBN.sub(
|
||||
summary.current_order_total,
|
||||
creditLineTotal
|
||||
)
|
||||
|
||||
summary.transaction_total = MathBN.sum(
|
||||
...this.transactions.map((tr) => tr.amount)
|
||||
)
|
||||
|
||||
summary.current_order_total = MathBN.sub(
|
||||
summary.current_order_total,
|
||||
creditLineTotal
|
||||
)
|
||||
|
||||
summary.pending_difference = MathBN.sub(
|
||||
summary.current_order_total,
|
||||
summary.transaction_total
|
||||
@@ -216,6 +228,7 @@ export class OrderChangeProcessing {
|
||||
paid_total: new BigNumber(summary.paid_total),
|
||||
refunded_total: new BigNumber(summary.refunded_total),
|
||||
credit_line_total: new BigNumber(summary.credit_line_total),
|
||||
accounting_total: new BigNumber(summary.accounting_total),
|
||||
} as unknown as OrderSummaryDTO
|
||||
|
||||
return orderSummary
|
||||
|
||||
Reference in New Issue
Block a user