feat(medusa,core-flows): complete cart [part-1] (#7201)

what:

- adds a very basic complete cart endpoint that creates an order
- the complete cart workflow currently does the following:
  - create tax lines
  - create order
This commit is contained in:
Riqwan Thamir
2024-05-03 00:24:50 +02:00
committed by GitHub
parent e78362c000
commit f129415650
19 changed files with 554 additions and 34 deletions

View File

@@ -0,0 +1,7 @@
---
"@medusajs/core-flows": patch
"@medusajs/medusa": patch
"@medusajs/types": patch
---
feat(medusa,core-flows,types): added a basic endpoint for complete cart

View File

@@ -16,18 +16,20 @@ import {
ITaxModuleService,
} from "@medusajs/types"
import {
ContainerRegistrationKeys,
PromotionRuleOperator,
PromotionType,
RuleOperator,
} from "@medusajs/utils"
import { medusaIntegrationTestRunner } from "medusa-test-utils"
import adminSeeder from "../../../../helpers/admin-seeder"
import { createAdminUser } from "../../../../helpers/create-admin-user"
import { createAuthenticatedCustomer } from "../../../helpers/create-authenticated-customer"
import { setupTaxStructure } from "../../fixtures"
jest.setTimeout(100000)
const env = { MEDUSA_FF_MEDUSA_V2: true }
const adminHeaders = { headers: { "x-medusa-access-token": "test_token" } }
medusaIntegrationTestRunner({
env,
@@ -44,8 +46,11 @@ medusaIntegrationTestRunner({
let promotionModule: IPromotionModuleService
let taxModule: ITaxModuleService
let fulfillmentModule: IFulfillmentModuleService
let remoteLinkService
let regionService: IRegionModuleService
let defaultRegion
let region
beforeAll(async () => {
appContainer = getContainer()
@@ -58,13 +63,17 @@ medusaIntegrationTestRunner({
remoteLink = appContainer.resolve(LinkModuleUtils.REMOTE_LINK)
promotionModule = appContainer.resolve(ModuleRegistrationName.PROMOTION)
taxModule = appContainer.resolve(ModuleRegistrationName.TAX)
regionService = appContainer.resolve(ModuleRegistrationName.REGION)
fulfillmentModule = appContainer.resolve(
ModuleRegistrationName.FULFILLMENT
)
remoteLinkService = appContainer.resolve(
ContainerRegistrationKeys.REMOTE_LINK
)
})
beforeEach(async () => {
await adminSeeder(dbConnection)
await createAdminUser(dbConnection, adminHeaders, appContainer)
// Here, so we don't have to create a region for each test
defaultRegion = await regionModule.create({
@@ -1389,6 +1398,196 @@ medusaIntegrationTestRunner({
)
})
})
describe("POST /store/carts/:id/complete", () => {
let salesChannel
let product
let shippingProfile
let fulfillmentSet
let shippingOption
beforeEach(async () => {
await setupTaxStructure(taxModule)
region = await regionService.create({
name: "Test region",
countries: ["US"],
currency_code: "usd",
})
salesChannel = (
await api.post(
"/admin/sales-channels",
{ name: "first channel", description: "channel" },
adminHeaders
)
).data.sales_channel
product = (
await api.post(
"/admin/products",
{
title: "Test fixture",
tags: [{ value: "123" }, { value: "456" }],
options: [
{ title: "size", values: ["large", "small"] },
{ title: "color", values: ["green"] },
],
variants: [
{
title: "Test variant",
inventory_quantity: 10,
prices: [
{
currency_code: "usd",
amount: 100,
},
],
options: {
size: "large",
color: "green",
},
},
],
},
adminHeaders
)
).data.product
const stockLocation = (
await api.post(
`/admin/stock-locations`,
{ name: "test location" },
adminHeaders
)
).data.stock_location
await api.post(
`/admin/stock-locations/${stockLocation.id}/sales-channels`,
{ add: [salesChannel.id] },
adminHeaders
)
shippingProfile = await fulfillmentModule.createShippingProfiles({
name: "Test",
type: "default",
})
fulfillmentSet = await fulfillmentModule.create({
name: "Test",
type: "test-type",
service_zones: [
{
name: "Test",
geo_zones: [{ type: "country", country_code: "us" }],
},
],
})
await remoteLinkService.create([
{
[Modules.FULFILLMENT]: { fulfillment_set_id: fulfillmentSet.id },
[Modules.STOCK_LOCATION]: { stock_location_id: stockLocation.id },
},
])
shippingOption = (
await api.post(
`/admin/shipping-options`,
{
name: "Test shipping option",
service_zone_id: fulfillmentSet.service_zones[0].id,
shipping_profile_id: shippingProfile.id,
provider_id: "manual_test-provider",
price_type: "flat",
type: {
label: "Test type",
description: "Test description",
code: "test-code",
},
prices: [
{ currency_code: "usd", amount: 1000 },
{ region_id: region.id, amount: 1100 },
],
rules: [],
},
adminHeaders
)
).data.shipping_option
})
it("should create an order", async () => {
const cartResponse = await api.post(`/store/carts`, {
currency_code: "usd",
email: "tony@stark-industries.com",
shipping_address: {
address_1: "test address 1",
address_2: "test address 2",
city: "ny",
country_code: "us",
province: "ny",
postal_code: "94016",
},
// TODO: inventory isn't being managed on a product level
// sales_channel_id: salesChannel.id,
items: [{ quantity: 1, variant_id: product.variants[0].id }],
})
const response = await api.post(
`/store/carts/${cartResponse.data.cart.id}/complete`,
{}
)
expect(response.status).toEqual(200)
expect(response.data.order).toEqual(
expect.objectContaining({
id: expect.any(String),
total: 106,
subtotal: 100,
tax_total: 6,
discount_total: 0,
discount_tax_total: 0,
original_total: 106,
original_tax_total: 6,
item_total: 106,
item_subtotal: 100,
item_tax_total: 6,
original_item_total: 106,
original_item_subtotal: 100,
original_item_tax_total: 6,
shipping_total: 0,
shipping_subtotal: 0,
shipping_tax_total: 0,
original_shipping_tax_total: 0,
original_shipping_tax_subtotal: 0,
original_shipping_total: 0,
items: [
expect.objectContaining({
product_id: product.id,
unit_price: 100,
quantity: 1,
tax_total: 6,
subtotal: 100,
total: 106,
original_total: 106,
discount_total: 0,
tax_lines: [
expect.objectContaining({
rate: 6,
}),
],
adjustments: [],
}),
],
shipping_address: expect.objectContaining({
city: "ny",
country_code: "us",
province: "ny",
postal_code: "94016",
}),
})
)
})
})
})
},
})

View File

@@ -0,0 +1,57 @@
import { CartWorkflowDTO } from "@medusajs/types"
import { OrderStatus } from "@medusajs/utils"
import { StepResponse, createStep } from "@medusajs/workflows-sdk"
import { createOrdersWorkflow } from "../../../order/workflows/create-orders"
interface StepInput {
cart: CartWorkflowDTO
}
export const createOrderFromCartStepId = "create-order-from-cart"
export const createOrderFromCartStep = createStep(
createOrderFromCartStepId,
async (input: StepInput, { container }) => {
const { cart } = input
const itemAdjustments = (cart.items || [])
?.map((item) => item.adjustments || [])
.flat(1)
const shippingAdjustments = (cart.shipping_methods || [])
?.map((sm) => sm.adjustments || [])
.flat(1)
const promoCodes = [...itemAdjustments, ...shippingAdjustments]
.map((adjustment) => adjustment.code)
.filter((code) => Boolean) as string[]
const { transaction, result } = await createOrdersWorkflow(container).run({
input: {
region_id: cart.region?.id,
customer_id: cart.customer?.id,
sales_channel_id: cart.sales_channel_id,
status: OrderStatus.PENDING,
email: cart.email,
currency_code: cart.currency_code,
shipping_address: cart.shipping_address,
billing_address: cart.billing_address,
// TODO: This should be handle correctly
no_notification: false,
items: cart.items,
shipping_methods: cart.shipping_methods,
metadata: cart.metadata,
promo_codes: promoCodes,
},
})
return new StepResponse(result, { transaction })
},
async (flow, { container }) => {
if (!flow) {
return
}
await createOrdersWorkflow(container).cancel({
transaction: flow.transaction,
})
}
)

View File

@@ -3,6 +3,7 @@ export * from "./add-to-cart"
export * from "./confirm-inventory"
export * from "./create-carts"
export * from "./create-line-item-adjustments"
export * from "./create-order-from-cart"
export * from "./create-shipping-method-adjustments"
export * from "./find-one-or-any-region"
export * from "./find-or-create-customer"

View File

@@ -10,3 +10,43 @@ export const cartFieldsForRefreshSteps = [
"customer.*",
"customer.groups.*",
]
export const completeCartFields = [
"id",
"currency_code",
"email",
"created_at",
"updated_at",
"total",
"subtotal",
"tax_total",
"discount_total",
"discount_tax_total",
"original_total",
"original_tax_total",
"item_total",
"item_subtotal",
"item_tax_total",
"sales_channel_id",
"original_item_total",
"original_item_subtotal",
"original_item_tax_total",
"shipping_total",
"shipping_subtotal",
"shipping_tax_total",
"original_shipping_tax_total",
"original_shipping_tax_subtotal",
"original_shipping_total",
"items.*",
"items.tax_lines.*",
"items.adjustments.*",
"customer.*",
"shipping_methods.*",
"shipping_methods.tax_lines.*",
"shipping_methods.adjustments.*",
"shipping_address.*",
"billing_address.*",
"region.*",
"payment_collection.*",
"payment_collection.payment_sessions.*",
]

View File

@@ -1,15 +1,30 @@
import { BigNumberInput, ProductVariantDTO } from "@medusajs/types"
import {
BigNumberInput,
CreateOrderAdjustmentDTO,
CreateOrderLineItemTaxLineDTO,
ProductVariantDTO,
} from "@medusajs/types"
interface Input {
quantity: BigNumberInput
metadata?: Record<string, any>
unitPrice: BigNumberInput
variant: ProductVariantDTO
taxLines?: CreateOrderLineItemTaxLineDTO[]
adjustments?: CreateOrderAdjustmentDTO[]
cartId?: string
}
export function prepareLineItemData(data: Input) {
const { variant, unitPrice, quantity, metadata, cartId } = data
const {
variant,
unitPrice,
quantity,
metadata,
cartId,
taxLines,
adjustments,
} = data
if (!variant.product) {
throw new Error("Variant does not have a product")
@@ -39,9 +54,37 @@ export function prepareLineItemData(data: Input) {
metadata,
}
if (taxLines) {
lineItem.tax_lines = prepareTaxLinesData(taxLines)
}
if (adjustments) {
lineItem.adjustments = prepareAdjustmentsData(adjustments)
}
if (cartId) {
lineItem.cart_id = cartId
}
return lineItem
}
export function prepareTaxLinesData(data: CreateOrderLineItemTaxLineDTO[]) {
return data.map((d) => ({
description: d.description,
tax_rate_id: d.tax_rate_id,
code: d.code,
rate: d.rate,
provider_id: d.provider_id,
}))
}
export function prepareAdjustmentsData(data: CreateOrderAdjustmentDTO[]) {
return data.map((d) => ({
code: d.code,
amount: d.amount,
description: d.description,
promotion_id: d.promotion_id,
provider_id: d.promotion_id,
}))
}

View File

@@ -0,0 +1,43 @@
import { CompleteCartWorkflowInputDTO, OrderDTO } from "@medusajs/types"
import { WorkflowData, createWorkflow } from "@medusajs/workflows-sdk"
import { useRemoteQueryStep } from "../../../common"
import { createOrderFromCartStep } from "../steps"
import { updateTaxLinesStep } from "../steps/update-tax-lines"
import { completeCartFields } from "../utils/fields"
/*
- [] Create Tax Lines
- [] Authorize Payment
- fail:
- [] Delete Tax lines
- [] Reserve Item from inventory (if enabled)
- fail:
- [] Delete reservations
- [] Cancel Payment
- [] Create order
*/
export const completeCartWorkflowId = "complete-cart"
export const completeCartWorkflow = createWorkflow(
completeCartWorkflowId,
(
input: WorkflowData<CompleteCartWorkflowInputDTO>
): WorkflowData<OrderDTO> => {
const cart = useRemoteQueryStep({
entry_point: "cart",
fields: completeCartFields,
variables: { id: input.id },
list: false,
})
updateTaxLinesStep({ cart_or_cart_id: cart, force_tax_calculation: true })
const finalCart = useRemoteQueryStep({
entry_point: "cart",
fields: completeCartFields,
variables: { id: input.id },
list: false,
}).config({ name: "final-cart" })
return createOrderFromCartStep({ cart: finalCart })
}
)

View File

@@ -1,5 +1,6 @@
export * from "./add-shipping-method-to-cart"
export * from "./add-to-cart"
export * from "./complete-cart"
export * from "./create-carts"
export * from "./create-payment-collection-for-cart"
export * from "./list-shipping-options-for-cart"

View File

@@ -7,15 +7,13 @@ import {
transform,
} from "@medusajs/workflows-sdk"
import { useRemoteQueryStep } from "../../common"
import {
confirmInventoryStep,
findOneOrAnyRegionStep,
findOrCreateCustomerStep,
findSalesChannelStep,
getVariantPriceSetsStep,
getVariantsStep,
validateVariantsExistStep,
} from "../../definition/cart"
import { confirmInventoryStep } from "../../definition/cart/steps/confirm-inventory"
import { findOneOrAnyRegionStep } from "../../definition/cart/steps/find-one-or-any-region"
import { findOrCreateCustomerStep } from "../../definition/cart/steps/find-or-create-customer"
import { findSalesChannelStep } from "../../definition/cart/steps/find-sales-channel"
import { getVariantPriceSetsStep } from "../../definition/cart/steps/get-variant-price-sets"
import { getVariantsStep } from "../../definition/cart/steps/get-variants"
import { validateVariantsExistStep } from "../../definition/cart/steps/validate-variants-existence"
import { prepareConfirmInventoryInput } from "../../definition/cart/utils/prepare-confirm-inventory-input"
import { prepareLineItemData } from "../../definition/cart/utils/prepare-line-item-data"
import { createOrdersStep, updateOrderTaxLinesStep } from "../steps"
@@ -174,6 +172,8 @@ export const createOrdersWorkflow = createWorkflow(
),
quantity: item.quantity as number,
metadata: item?.metadata ?? {},
taxLines: item.tax_lines || [],
adjustments: item.adjustments || [],
})
})

View File

@@ -0,0 +1,34 @@
import { completeCartWorkflow } from "@medusajs/core-flows"
import { MedusaRequest, MedusaResponse } from "../../../../../types/routing"
import { refetchOrder } from "../../../orders/helpers"
import { StoreCompleteCartType } from "../../validators"
export const POST = async (
req: MedusaRequest<StoreCompleteCartType>,
res: MedusaResponse
) => {
const { idempotency_key: idempotencyKey } = req.validatedBody
// If the idempotencyKey is present:
// - is workflow is running?
// = throw error
// - else
// - re-run the workflow at the failed step
const { errors, result } = await completeCartWorkflow(req.scope).run({
input: { id: req.params.id },
throwOnError: false,
})
if (Array.isArray(errors) && errors[0]) {
throw errors[0].error
}
const order = await refetchOrder(
result.id,
req.scope,
req.remoteQueryConfig.fields
)
res.status(200).json({ order })
}

View File

@@ -2,12 +2,15 @@ import { MiddlewareRoute } from "../../../loaders/helpers/routing/types"
import { authenticate } from "../../../utils/authenticate-middleware"
import { validateAndTransformBody } from "../../utils/validate-body"
import { validateAndTransformQuery } from "../../utils/validate-query"
import * as OrderQueryConfig from "../orders/query-config"
import { StoreGetOrder } from "../orders/validators"
import * as QueryConfig from "./query-config"
import {
StoreAddCartLineItem,
StoreAddCartPromotions,
StoreAddCartShippingMethods,
StoreCalculateCartTaxes,
StoreCompleteCart,
StoreCreateCart,
StoreGetCartsCart,
StoreRemoveCartPromotions,
@@ -144,4 +147,15 @@ export const storeCartRoutesMiddlewares: MiddlewareRoute[] = [
),
],
},
{
method: ["POST"],
matcher: "/store/carts/:id/complete",
middlewares: [
validateAndTransformBody(StoreCompleteCart),
validateAndTransformQuery(
StoreGetOrder,
OrderQueryConfig.retrieveTransformQueryConfig
),
],
},
]

View File

@@ -82,3 +82,10 @@ export const StoreAddCartShippingMethods = z
data: z.record(z.unknown()).optional(),
})
.strict()
export const StoreCompleteCart = z
.object({
idempotency_key: z.string().optional(),
})
.strict()
export type StoreCompleteCartType = z.infer<typeof StoreCompleteCart>

View File

@@ -0,0 +1,10 @@
import { MedusaContainer } from "@medusajs/types"
import { refetchEntity } from "../../utils/refetch-entity"
export const refetchOrder = async (
idOrFilter: string | object,
scope: MedusaContainer,
fields: string[]
) => {
return await refetchEntity("order", idOrFilter, scope, fields)
}

View File

@@ -0,0 +1,55 @@
// TODO: This is copied over from admin. Scope what fields and relations are allowed for store
export const defaultStoreOrderFields = [
"id",
"status",
"version",
"summary",
"metadata",
"created_at",
"updated_at",
]
export const defaultStoreRetrieveOrderFields = [
"id",
"status",
"version",
"summary",
"total",
"subtotal",
"tax_total",
"discount_total",
"discount_tax_total",
"original_total",
"original_tax_total",
"item_total",
"item_subtotal",
"item_tax_total",
"original_item_total",
"original_item_subtotal",
"original_item_tax_total",
"shipping_total",
"shipping_subtotal",
"shipping_tax_total",
"original_shipping_tax_total",
"original_shipping_tax_subtotal",
"original_shipping_total",
"created_at",
"updated_at",
"*items",
"*items.tax_lines",
"*items.adjustments",
"*items.detail",
"*items.tax_lines",
"*items.adjustments",
"*shipping_address",
"*billing_address",
"*shipping_methods",
"*shipping_methods.tax_lines",
"*shipping_methods.adjustments",
]
export const retrieveTransformQueryConfig = {
defaultFields: defaultStoreRetrieveOrderFields,
isList: false,
}

View File

@@ -0,0 +1,5 @@
import { z } from "zod"
import { createSelectParams } from "../../utils/validators"
export const StoreGetOrder = createSelectParams()
export type StoreGetOrderType = z.infer<typeof StoreGetOrder>

View File

@@ -12,7 +12,7 @@ export interface PartialUpsertOrderItemDTO {
return_dismissed_quantity?: BigNumberInput
written_off_quantity?: BigNumberInput
metadata?: Record<string, unknown>
metadata?: Record<string, unknown> | null
}
export interface CreateOrderItemDTO extends PartialUpsertOrderItemDTO {

View File

@@ -8,7 +8,7 @@ export interface CreateOrderDTO {
currency_code: string
status?: OrderStatus
no_notification?: boolean
metadata?: Record<string, unknown>
metadata?: Record<string, unknown> | null
}
export interface UpdateOrderDTO {
@@ -21,5 +21,5 @@ export interface UpdateOrderDTO {
currency_code?: string
status?: OrderStatus
no_notification?: boolean
metadata?: Record<string, unknown>
metadata?: Record<string, unknown> | null
}

View File

@@ -118,3 +118,7 @@ export interface ListShippingOptionsForCartWorkflowInputDTO {
export interface PricedShippingOptionDTO extends ShippingOptionDTO {
amount: BigNumberInput
}
export interface CompleteCartWorkflowInputDTO {
id: string
}

View File

@@ -14,7 +14,7 @@ export interface UpsertOrderAddressDTO {
province?: string
postal_code?: string
phone?: string
metadata?: Record<string, unknown>
metadata?: Record<string, unknown> | null
}
export interface UpdateOrderAddressDTO extends UpsertOrderAddressDTO {
@@ -41,7 +41,7 @@ export interface CreateOrderDTO {
items?: CreateOrderLineItemDTO[]
shipping_methods?: Omit<CreateOrderShippingMethodDTO, "order_id">[]
transactions?: Omit<CreateOrderTransactionDTO, "order_id">[]
metadata?: Record<string, unknown>
metadata?: Record<string, unknown> | null
promo_codes?: string[]
}
@@ -57,7 +57,7 @@ export interface UpdateOrderDTO {
billing_address?: CreateOrderAddressDTO | UpdateOrderAddressDTO
email?: string
no_notification?: boolean
metadata?: Record<string, unknown>
metadata?: Record<string, unknown> | null
}
/** ORDER END */
@@ -167,7 +167,7 @@ export interface CreateOrderLineItemDTO {
tax_lines?: CreateOrderTaxLineDTO[]
adjustments?: CreateOrderAdjustmentDTO[]
metadata?: Record<string, unknown>
metadata?: Record<string, unknown> | null
}
export interface CreateOrderLineItemForOrderDTO extends CreateOrderLineItemDTO {
@@ -248,7 +248,7 @@ export interface CreateOrderChangeDTO {
requested_by?: string
requested_at?: Date
created_by?: string
metadata?: Record<string, unknown>
metadata?: Record<string, unknown> | null
actions?: CreateOrderChangeActionDTO[]
}
@@ -265,25 +265,25 @@ export interface UpdateOrderChangeDTO {
declined_reason?: string
declined_at?: Date
canceled_by?: string
metadata?: Record<string, unknown>
metadata?: Record<string, unknown> | null
}
export interface CancelOrderChangeDTO {
id: string
canceled_by?: string
metadata?: Record<string, unknown>
metadata?: Record<string, unknown> | null
}
export interface DeclineOrderChangeDTO {
id: string
declined_by?: string
metadata?: Record<string, unknown>
metadata?: Record<string, unknown> | null
}
export interface ConfirmOrderChangeDTO {
id: string
confirmed_by?: string
metadata?: Record<string, unknown>
metadata?: Record<string, unknown> | null
}
/** ORDER CHANGE END */
@@ -314,7 +314,7 @@ export interface CreateOrderTransactionDTO {
currency_code: string
reference?: string
reference_id?: string
metadata?: Record<string, unknown>
metadata?: Record<string, unknown> | null
}
export interface UpdateOrderTransactionDTO {
@@ -323,7 +323,7 @@ export interface UpdateOrderTransactionDTO {
currency_code?: string
reference?: string
reference_id?: string
metadata?: Record<string, unknown>
metadata?: Record<string, unknown> | null
}
/** ORDER TRANSACTION END */
@@ -340,7 +340,7 @@ export interface UpdateOrderItemDTO {
return_received_quantity?: BigNumberInput
return_dismissed_quantity?: BigNumberInput
written_off_quantity?: BigNumberInput
metadata?: Record<string, unknown>
metadata?: Record<string, unknown> | null
}
export interface UpdateOrderItemWithSelectorDTO {
@@ -363,9 +363,9 @@ export interface RegisterOrderFulfillmentDTO {
id: string
quantity: BigNumberInput
internal_note?: string
metadata?: Record<string, unknown>
metadata?: Record<string, unknown> | null
}[]
metadata?: Record<string, unknown>
metadata?: Record<string, unknown> | null
}
export interface RegisterOrderShipmentDTO {
@@ -379,9 +379,9 @@ export interface RegisterOrderShipmentDTO {
id: string
quantity: BigNumberInput
internal_note?: string
metadata?: Record<string, unknown>
metadata?: Record<string, unknown> | null
}[]
metadata?: Record<string, unknown>
metadata?: Record<string, unknown> | null
}
export interface CreateOrderReturnDTO {
@@ -395,9 +395,9 @@ export interface CreateOrderReturnDTO {
id: string
quantity: BigNumberInput
internal_note?: string
metadata?: Record<string, unknown>
metadata?: Record<string, unknown> | null
}[]
metadata?: Record<string, unknown>
metadata?: Record<string, unknown> | null
}
/** ORDER bundled action flows */