feat(medusa, types, utils, core-flows, order) request & accept order transfer (#10106)

**What**
- add request order transfer workflow
- add admin endpoint for transferring an order to a customer
- accept order transfer storefront endpoint
- accept transfer workflow
- changes in the order module to introduce new change and action types

---

**Note**
- we return 400 instead 409 currently if there is already an active order edit, I will revisit this in a followup
- endpoint for requesting order transfer from the storefront will be added in a separate PR

---

RESOLVES CMRC-701
RESOLVES CMRC-703
RESOLVES CMRC-704
RESOLVES CMRC-705
This commit is contained in:
Frane Polić
2024-11-19 09:53:22 +01:00
committed by GitHub
parent b1b7a4abf1
commit 36460a3a07
21 changed files with 660 additions and 4 deletions

View File

@@ -0,0 +1,233 @@
import { medusaIntegrationTestRunner } from "@medusajs/test-utils"
import {
adminHeaders,
createAdminUser,
generatePublishableKey,
generateStoreHeaders,
} from "../../../../helpers/create-admin-user"
import { createOrderSeeder } from "../../fixtures/order"
jest.setTimeout(300000)
medusaIntegrationTestRunner({
testSuite: ({ dbConnection, getContainer, api }) => {
let order
let customer
let user
let storeHeaders
beforeEach(async () => {
const container = getContainer()
user = (await createAdminUser(dbConnection, adminHeaders, container)).user
const publishableKey = await generatePublishableKey(container)
storeHeaders = generateStoreHeaders({ publishableKey })
const seeders = await createOrderSeeder({ api, container })
const registeredCustomerToken = (
await api.post("/auth/customer/emailpass/register", {
email: "test@email.com",
password: "password",
})
).data.token
customer = (
await api.post(
"/store/customers",
{
email: "test@email.com",
},
{
headers: {
Authorization: `Bearer ${registeredCustomerToken}`,
...storeHeaders.headers,
},
}
)
).data.customer
order = seeders.order
})
describe("Transfer Order flow", () => {
it("should pass order transfer flow from admin successfully", async () => {
// 1. Admin requests order transfer for a customer with an account
await api.post(
`/admin/orders/${order.id}/transfer`,
{
customer_id: customer.id,
},
adminHeaders
)
const orderResult = (
await api.get(
`/admin/orders/${order.id}?fields=+customer_id,+email`,
adminHeaders
)
).data.order
// 2. Order still belongs to the guest customer since the transfer hasn't been accepted yet
expect(orderResult.email).toEqual("tony@stark-industries.com")
expect(orderResult.customer_id).not.toEqual(customer.id)
const orderPreviewResult = (
await api.get(`/admin/orders/${order.id}/preview`, adminHeaders)
).data.order
expect(orderPreviewResult).toEqual(
expect.objectContaining({
customer_id: customer.id,
order_change: expect.objectContaining({
change_type: "transfer",
status: "requested",
requested_by: user.id,
}),
})
)
const orderChangesResult = (
await api.get(`/admin/orders/${order.id}/changes`, adminHeaders)
).data.order_changes
expect(orderChangesResult.length).toEqual(1)
expect(orderChangesResult[0]).toEqual(
expect.objectContaining({
change_type: "transfer",
status: "requested",
requested_by: user.id,
created_by: user.id,
confirmed_by: null,
confirmed_at: null,
declined_by: null,
actions: expect.arrayContaining([
expect.objectContaining({
version: 2,
action: "TRANSFER_CUSTOMER",
reference: "customer",
reference_id: customer.id,
details: expect.objectContaining({
token: expect.any(String),
original_email: "tony@stark-industries.com",
}),
}),
]),
})
)
// 3. Guest customer who received the token accepts the transfer
await api.post(
`/store/orders/${order.id}/transfer/accept`,
{ token: orderChangesResult[0].actions[0].details.token },
{
headers: {
...storeHeaders.headers,
},
}
)
const finalOrderResult = (
await api.get(
`/admin/orders/${order.id}?fields=+customer_id,+email`,
adminHeaders
)
).data.order
expect(finalOrderResult.email).toEqual("tony@stark-industries.com")
// 4. Customer account is now associated with the order (email on the order is still as original, guest email)
expect(finalOrderResult.customer_id).toEqual(customer.id)
})
it("should fail to request order transfer to a guest customer", async () => {
const customer = (
await api.post(
"/admin/customers",
{
first_name: "guest",
email: "guest@medusajs.com",
},
adminHeaders
)
).data.customer
const err = await api
.post(
`/admin/orders/${order.id}/transfer`,
{
customer_id: customer.id,
},
adminHeaders
)
.catch((e) => e)
expect(err.response.status).toBe(400)
expect(err.response.data).toEqual(
expect.objectContaining({
type: "invalid_data",
message: `Cannot transfer order: ${order.id} to a guest customer account: guest@medusajs.com`,
})
)
})
it("should fail to accept order transfer with invalid token", async () => {
await api.post(
`/admin/orders/${order.id}/transfer`,
{
customer_id: customer.id,
},
adminHeaders
)
const orderChangesResult = (
await api.get(`/admin/orders/${order.id}/changes`, adminHeaders)
).data.order_changes
expect(orderChangesResult.length).toEqual(1)
expect(orderChangesResult[0]).toEqual(
expect.objectContaining({
change_type: "transfer",
status: "requested",
requested_by: user.id,
created_by: user.id,
confirmed_by: null,
confirmed_at: null,
declined_by: null,
actions: expect.arrayContaining([
expect.objectContaining({
version: 2,
action: "TRANSFER_CUSTOMER",
reference: "customer",
reference_id: customer.id,
details: expect.objectContaining({
token: expect.any(String),
original_email: "tony@stark-industries.com",
}),
}),
]),
})
)
const err = await api
.post(
`/store/orders/${order.id}/transfer/accept`,
{ token: "fake-token" },
{
headers: {
...storeHeaders.headers,
},
}
)
.catch((e) => e)
expect(err.response.status).toBe(400)
expect(err.response.data).toEqual(
expect.objectContaining({
type: "not_allowed",
message: `Invalid token.`,
})
)
})
})
},
})

View File

@@ -78,3 +78,5 @@ export * from "./return/update-return-shipping-method"
export * from "./update-order-change-actions"
export * from "./update-order-changes"
export * from "./update-tax-lines"
export * from "./transfer/request-order-transfer"
export * from "./transfer/accept-order-transfer"

View File

@@ -0,0 +1,109 @@
import {
OrderChangeDTO,
OrderDTO,
OrderWorkflow,
} from "@medusajs/framework/types"
import {
WorkflowData,
WorkflowResponse,
createStep,
createWorkflow,
} from "@medusajs/framework/workflows-sdk"
import { OrderPreviewDTO } from "@medusajs/types"
import { useRemoteQueryStep } from "../../../common"
import { throwIfOrderIsCancelled } from "../../utils/order-validation"
import { previewOrderChangeStep } from "../../steps"
import {
ChangeActionType,
MedusaError,
OrderChangeStatus,
} from "@medusajs/utils"
import { confirmOrderChanges } from "../../steps/confirm-order-changes"
/**
* This step validates that an order transfer can be accepted.
*/
export const acceptOrderTransferValidationStep = createStep(
"accept-order-transfer-validation",
async function ({
token,
order,
orderChange,
}: {
token: string
order: OrderDTO
orderChange: OrderChangeDTO
}) {
throwIfOrderIsCancelled({ order })
if (!orderChange || orderChange.change_type !== "transfer") {
throw new MedusaError(
MedusaError.Types.INVALID_DATA,
`Order ${order.id} does not have an order transfer request.`
)
}
const transferCustomerAction = orderChange.actions.find(
(a) => a.action === ChangeActionType.TRANSFER_CUSTOMER
)
if (!token.length || token !== transferCustomerAction?.details!.token) {
throw new MedusaError(MedusaError.Types.NOT_ALLOWED, "Invalid token.")
}
}
)
export const acceptOrderTransferWorkflowId = "accept-order-transfer-workflow"
/**
* This workflow accepts an order transfer.
*/
export const acceptOrderTransferWorkflow = createWorkflow(
acceptOrderTransferWorkflowId,
function (
input: WorkflowData<OrderWorkflow.AcceptOrderTransferWorkflowInput>
): WorkflowResponse<OrderPreviewDTO> {
const order: OrderDTO = useRemoteQueryStep({
entry_point: "orders",
fields: ["id", "email", "status", "customer_id"],
variables: { id: input.order_id },
list: false,
throw_if_key_not_found: true,
})
const orderChange: OrderChangeDTO = useRemoteQueryStep({
entry_point: "order_change",
fields: [
"id",
"status",
"change_type",
"actions.id",
"actions.order_id",
"actions.action",
"actions.details",
"actions.reference",
"actions.reference_id",
"actions.internal_note",
],
variables: {
filters: {
order_id: input.order_id,
status: [OrderChangeStatus.REQUESTED],
},
},
list: false,
}).config({ name: "order-change-query" })
acceptOrderTransferValidationStep({
order,
orderChange,
token: input.token,
})
confirmOrderChanges({
changes: [orderChange],
orderId: order.id,
})
return new WorkflowResponse(previewOrderChangeStep(input.order_id))
}
)

View File

@@ -0,0 +1,136 @@
import { OrderDTO, OrderWorkflow } from "@medusajs/framework/types"
import {
WorkflowData,
WorkflowResponse,
createStep,
createWorkflow,
transform,
} from "@medusajs/framework/workflows-sdk"
import { CustomerDTO, OrderPreviewDTO } from "@medusajs/types"
import { v4 as uid } from "uuid"
import { emitEventStep, useRemoteQueryStep } from "../../../common"
import { createOrderChangeStep } from "../../steps/create-order-change"
import { throwIfOrderIsCancelled } from "../../utils/order-validation"
import { createOrderChangeActionsWorkflow } from "../create-order-change-actions"
import {
ChangeActionType,
MedusaError,
OrderChangeStatus,
OrderWorkflowEvents,
} from "@medusajs/utils"
import { previewOrderChangeStep, updateOrderChangesStep } from "../../steps"
/**
* This step validates that an order transfer can be requested.
*/
export const requestOrderTransferValidationStep = createStep(
"request-order-transfer-validation",
async function ({
order,
customer,
}: {
order: OrderDTO
customer: CustomerDTO
}) {
throwIfOrderIsCancelled({ order })
if (!customer.has_account) {
throw new MedusaError(
MedusaError.Types.INVALID_DATA,
`Cannot transfer order: ${order.id} to a guest customer account: ${customer.email}`
)
}
if (order.customer_id === customer.id) {
throw new MedusaError(
MedusaError.Types.INVALID_DATA,
`Order: ${order.id} already belongs to customer: ${customer.id}`
)
}
}
)
export const requestOrderTransferWorkflowId = "request-order-transfer-workflow"
/**
* This workflow requests an order transfer.
*/
export const requestOrderTransferWorkflow = createWorkflow(
requestOrderTransferWorkflowId,
function (
input: WorkflowData<OrderWorkflow.RequestOrderTransferWorkflowInput>
): WorkflowResponse<OrderPreviewDTO> {
const order: OrderDTO = useRemoteQueryStep({
entry_point: "orders",
fields: ["id", "email", "status", "customer_id"],
variables: { id: input.order_id },
list: false,
throw_if_key_not_found: true,
})
const customer: CustomerDTO = useRemoteQueryStep({
entry_point: "customers",
fields: ["id", "email", "has_account"],
variables: { id: input.customer_id },
list: false,
throw_if_key_not_found: true,
}).config({ name: "customer-query" })
requestOrderTransferValidationStep({ order, customer })
const orderChangeInput = transform({ input }, ({ input }) => {
return {
change_type: "transfer" as const,
order_id: input.order_id,
created_by: input.logged_in_user,
description: input.description,
internal_note: input.internal_note,
}
})
const change = createOrderChangeStep(orderChangeInput)
const actionInput = transform(
{ order, input, change },
({ order, input, change }) => [
{
order_change_id: change.id,
order_id: input.order_id,
action: ChangeActionType.TRANSFER_CUSTOMER,
version: change.version,
reference: "customer",
reference_id: input.customer_id,
details: {
token: uid(),
original_email: order.email,
},
},
]
)
createOrderChangeActionsWorkflow.runAsStep({
input: actionInput,
})
const updateOrderChangeInput = transform(
{ input, change },
({ input, change }) => [
{
id: change.id,
status: OrderChangeStatus.REQUESTED,
requested_by: input.logged_in_user,
requested_at: new Date(),
},
]
)
updateOrderChangesStep(updateOrderChangeInput)
emitEventStep({
eventName: OrderWorkflowEvents.TRANSFER_REQUESTED,
data: { id: input.order_id },
})
return new WorkflowResponse(previewOrderChangeStep(input.order_id))
}
)

View File

@@ -24,6 +24,7 @@ export type ChangeActionType =
| "SHIP_ITEM"
| "WRITE_OFF_ITEM"
| "REINSTATE_ITEM"
| "TRANSFER_CUSTOMER"
export type OrderChangeStatus =
| "confirmed"
@@ -2116,7 +2117,7 @@ export interface OrderChangeDTO {
/**
* The type of the order change
*/
change_type?: "return" | "exchange" | "claim" | "edit"
change_type?: "return" | "exchange" | "claim" | "edit" | "transfer"
/**
* The ID of the associated order

View File

@@ -866,6 +866,7 @@ export interface CreateOrderChangeDTO {
| "exchange"
| "claim"
| "edit"
| "transfer"
/**
* The description of the order change.

View File

@@ -0,0 +1,4 @@
export interface AcceptOrderTransferWorkflowInput {
order_id: string
token: string
}

View File

@@ -15,3 +15,5 @@ export * from "./receive-return"
export * from "./request-item-return"
export * from "./shipping-method"
export * from "./update-return"
export * from "./request-transfer"
export * from "./accept-transfer"

View File

@@ -0,0 +1,8 @@
export interface RequestOrderTransferWorkflowInput {
order_id: string
customer_id: string
logged_in_user: string
description?: string
internal_note?: string
}

View File

@@ -25,6 +25,8 @@ export const OrderWorkflowEvents = {
CLAIM_CREATED: "order.claim_created",
EXCHANGE_CREATED: "order.exchange_created",
TRANSFER_REQUESTED: "order.transfer_requested",
}
export const UserWorkflowEvents = {

View File

@@ -14,4 +14,5 @@ export enum ChangeActionType {
SHIP_ITEM = "SHIP_ITEM",
WRITE_OFF_ITEM = "WRITE_OFF_ITEM",
REINSTATE_ITEM = "REINSTATE_ITEM",
TRANSFER_CUSTOMER = "TRANSFER_CUSTOMER",
}

View File

@@ -0,0 +1,39 @@
import { requestOrderTransferWorkflow } from "@medusajs/core-flows"
import {
AuthenticatedMedusaRequest,
MedusaResponse,
} from "@medusajs/framework/http"
import { HttpTypes } from "@medusajs/framework/types"
import {
ContainerRegistrationKeys,
remoteQueryObjectFromString,
} from "@medusajs/framework/utils"
import { AdminTransferOrderType } from "../../validators"
export const POST = async (
req: AuthenticatedMedusaRequest<AdminTransferOrderType>,
res: MedusaResponse<HttpTypes.AdminOrderResponse>
) => {
const remoteQuery = req.scope.resolve(ContainerRegistrationKeys.REMOTE_QUERY)
const variables = { id: req.params.id }
await requestOrderTransferWorkflow(req.scope).run({
input: {
order_id: req.params.id,
customer_id: req.validatedBody.customer_id,
logged_in_user: req.auth_context.actor_id,
description: req.validatedBody.description,
internal_note: req.validatedBody.internal_note,
},
})
const queryObject = remoteQueryObjectFromString({
entryPoint: "order",
variables,
fields: req.remoteQueryConfig.fields,
})
const [order] = await remoteQuery(queryObject)
res.status(200).json({ order })
}

View File

@@ -14,6 +14,7 @@ import {
AdminOrderChanges,
AdminOrderCreateFulfillment,
AdminOrderCreateShipment,
AdminTransferOrder,
} from "./validators"
export const adminOrderRoutesMiddlewares: MiddlewareRoute[] = [
@@ -144,4 +145,15 @@ export const adminOrderRoutesMiddlewares: MiddlewareRoute[] = [
),
],
},
{
method: ["POST"],
matcher: "/admin/orders/:id/transfer",
middlewares: [
validateAndTransformBody(AdminTransferOrder),
validateAndTransformQuery(
AdminGetOrdersOrderParams,
QueryConfig.retrieveTransformQueryConfig
),
],
},
]

View File

@@ -120,3 +120,10 @@ export type AdminMarkOrderFulfillmentDeliveredType = z.infer<
typeof AdminMarkOrderFulfillmentDelivered
>
export const AdminMarkOrderFulfillmentDelivered = z.object({})
export type AdminTransferOrderType = z.infer<typeof AdminTransferOrder>
export const AdminTransferOrder = z.object({
customer_id: z.string(),
description: z.string().optional(),
internal_note: z.string().optional(),
})

View File

@@ -0,0 +1,29 @@
import { AuthenticatedMedusaRequest, MedusaResponse } from "@medusajs/framework"
import { HttpTypes } from "@medusajs/framework/types"
import {
acceptOrderTransferWorkflow,
getOrderDetailWorkflow,
} from "@medusajs/core-flows"
import { StoreAcceptOrderTransferType } from "../../../validators"
export const POST = async (
req: AuthenticatedMedusaRequest<StoreAcceptOrderTransferType>,
res: MedusaResponse<HttpTypes.StoreOrderResponse>
) => {
await acceptOrderTransferWorkflow(req.scope).run({
input: {
order_id: req.params.id,
token: req.validatedBody.token,
},
})
const { result } = await getOrderDetailWorkflow(req.scope).run({
input: {
fields: req.remoteQueryConfig.fields,
order_id: req.params.id,
},
})
res.status(200).json({ order: result as HttpTypes.StoreOrder })
}

View File

@@ -1,8 +1,15 @@
import { MiddlewareRoute } from "@medusajs/framework/http"
import {
MiddlewareRoute,
validateAndTransformBody,
} from "@medusajs/framework/http"
import { authenticate } from "../../../utils/middlewares/authenticate-middleware"
import { validateAndTransformQuery } from "@medusajs/framework"
import * as QueryConfig from "./query-config"
import { StoreGetOrderParams, StoreGetOrdersParams } from "./validators"
import {
StoreGetOrderParams,
StoreGetOrdersParams,
StoreAcceptOrderTransfer,
} from "./validators"
export const storeOrderRoutesMiddlewares: MiddlewareRoute[] = [
{
@@ -26,4 +33,15 @@ export const storeOrderRoutesMiddlewares: MiddlewareRoute[] = [
),
],
},
{
method: ["POST"],
matcher: "/store/orders/:id/transfer/accept",
middlewares: [
validateAndTransformBody(StoreAcceptOrderTransfer),
validateAndTransformQuery(
StoreGetOrderParams,
QueryConfig.retrieveTransformQueryConfig
),
],
},
]

View File

@@ -18,3 +18,11 @@ export const StoreGetOrdersParams = createFindParams({
.merge(applyAndAndOrOperators(StoreGetOrdersParamsFields))
export type StoreGetOrdersParamsType = z.infer<typeof StoreGetOrdersParams>
export const StoreAcceptOrderTransfer = z.object({
token: z.string().min(1),
})
export type StoreAcceptOrderTransferType = z.infer<
typeof StoreAcceptOrderTransfer
>

View File

@@ -56,6 +56,8 @@ export type VirtualOrder = {
total: BigNumberInput
customer_id?: string
transactions?: OrderTransaction[]
metadata?: Record<string, unknown>
}

View File

@@ -13,3 +13,4 @@ export * from "./ship-item"
export * from "./shipping-add"
export * from "./shipping-remove"
export * from "./write-off-item"
export * from "./transfer-customer"

View File

@@ -0,0 +1,19 @@
import { ChangeActionType, MedusaError } from "@medusajs/framework/utils"
import { OrderChangeProcessing } from "../calculate-order-change"
import { setActionReference } from "../set-action-reference"
OrderChangeProcessing.registerActionType(ChangeActionType.TRANSFER_CUSTOMER, {
operation({ action, currentOrder, options }) {
currentOrder.customer_id = action.reference_id
setActionReference(currentOrder, action, options)
},
validate({ action }) {
if (!action.reference_id) {
throw new MedusaError(
MedusaError.Types.INVALID_DATA,
"Reference to customer ID is required"
)
}
},
})

View File

@@ -27,6 +27,13 @@ export function applyChangesToOrder(
const summariesToUpsert: any[] = []
const orderToUpdate: any[] = []
const orderEditableAttributes = [
"customer_id",
"sales_channel_id",
"email",
"no_notification",
]
const calculatedOrders = {}
for (const order of orders) {
const calculated = calculateOrderChange({
@@ -41,6 +48,17 @@ export function applyChangesToOrder(
calculatedOrders[order.id] = calculated
const version = actionsMap[order.id]?.[0]?.version ?? order.version
const orderAttributes: {
version?: number
customer_id?: string
} = {}
// Editable attributes that have changed
for (const attr of orderEditableAttributes) {
if (order[attr] !== calculated.order[attr]) {
orderAttributes[attr] = calculated.order[attr]
}
}
for (const item of calculated.order.items) {
if (MathBN.lte(item.quantity, 0)) {
@@ -113,12 +131,16 @@ export function applyChangesToOrder(
}
}
orderAttributes.version = version
}
if (Object.keys(orderAttributes).length > 0) {
orderToUpdate.push({
selector: {
id: order.id,
},
data: {
version,
...orderAttributes,
},
})
}