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

View 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

View File

@@ -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

View File

@@ -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",

View File

@@ -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

View File

@@ -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>
)
}

View File

@@ -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

View File

@@ -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,
})

View File

@@ -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,
})
}
)

View File

@@ -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 })
})
}
)

View File

@@ -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)
}
)

View File

@@ -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"

View File

@@ -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)
}
)

View File

@@ -1,3 +1,4 @@
export * from "./capture-payment"
export * from "./process-payment"
export * from "./refund-payment"
export * from "./refund-payments"

View File

@@ -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)
}
)

View File

@@ -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.
*/

View File

@@ -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
}
/**

View File

@@ -18,6 +18,7 @@ type OrderChangeType =
| "edit"
| "transfer"
| "update_order"
| "credit_line"
/** ADDRESS START */
/**

View File

@@ -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.
*/

View File

@@ -27,4 +27,5 @@ export enum OrderChangeType {
EXCHANGE = "exchange",
CLAIM = "claim",
EDIT = "edit",
CREDIT_LINE = "credit_line",
}

View File

@@ -46,6 +46,7 @@ export const defaultAdminRetrieveOrderFields = [
"*payment_collections",
"*payment_collections.payments",
"*payment_collections.payments.refunds",
"*payment_collections.payments.captures",
]
export const defaultAdminRetrieveOrderChangesFields = [

View File

@@ -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,
})
})
})

View File

@@ -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))

View File

@@ -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(

View File

@@ -84,6 +84,7 @@ export interface OrderSummaryCalculated {
paid_total: BigNumberInput
refunded_total: BigNumberInput
credit_line_total: BigNumberInput
accounting_total: BigNumberInput
}
export interface OrderTransaction {

View File

@@ -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)

View File

@@ -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