feat: Consolidate payment functions and handle sessions for storefront (#7231)

This commit is contained in:
Stevche Radevski
2024-05-06 15:14:40 +02:00
committed by GitHub
parent 16528c57b0
commit 4e12168dbd
25 changed files with 408 additions and 135 deletions

View File

@@ -1455,8 +1455,13 @@ medusaIntegrationTestRunner({
rules: [
{
operator: RuleOperator.EQ,
attribute: "shipping_address.province",
value: "ny",
attribute: "is_return",
value: "false",
},
{
operator: RuleOperator.EQ,
attribute: "enabled_in_store",
value: "true",
},
],
})

View File

@@ -1197,7 +1197,7 @@ medusaIntegrationTestRunner({
})
})
describe("POST /store/carts/:id/payment-collections", () => {
describe("POST /store/payment-collections", () => {
it("should create a payment collection for the cart", async () => {
const region = await regionModule.create({
name: "US",
@@ -1209,22 +1209,92 @@ medusaIntegrationTestRunner({
region_id: region.id,
})
const response = await api.post(
`/store/carts/${cart.id}/payment-collections`
)
const response = await api.post(`/store/payment-collections`, {
region_id: region.id,
cart_id: cart.id,
amount: 0,
currency_code: cart.currency_code,
})
expect(response.status).toEqual(200)
expect(response.data.cart).toEqual(
expect(response.data.payment_collection).toEqual(
expect.objectContaining({
id: cart.id,
currency_code: "usd",
payment_collection: expect.objectContaining({
id: expect.any(String),
amount: 0,
}),
id: expect.any(String),
amount: 0,
})
)
})
it("should return an existing payment collection for the cart", async () => {
const region = await regionModule.create({
name: "US",
currency_code: "usd",
})
const cart = await cartModule.create({
currency_code: "usd",
region_id: region.id,
})
const firstCollection = (
await api.post(`/store/payment-collections`, {
region_id: region.id,
cart_id: cart.id,
amount: 0,
currency_code: cart.currency_code,
})
).data.payment_collection
const response = await api.post(`/store/payment-collections`, {
region_id: region.id,
cart_id: cart.id,
amount: 0,
currency_code: cart.currency_code,
})
expect(response.status).toEqual(200)
expect(response.data.payment_collection.id).toEqual(
firstCollection.id
)
})
it("should create a new payment collection for a new cart", async () => {
const region = await regionModule.create({
name: "US",
currency_code: "usd",
})
const firstCart = await cartModule.create({
currency_code: "usd",
region_id: region.id,
})
const secondCart = await cartModule.create({
currency_code: "usd",
region_id: region.id,
})
const firstCollection = (
await api.post(`/store/payment-collections`, {
region_id: region.id,
cart_id: firstCart.id,
amount: 0,
currency_code: firstCart.currency_code,
})
).data.payment_collection
const secondCollection = (
await api.post(`/store/payment-collections`, {
region_id: region.id,
cart_id: secondCart.id,
amount: 0,
currency_code: secondCart.currency_code,
})
).data.payment_collection
expect(firstCollection.id).toBeTruthy()
expect(firstCollection.id).not.toEqual(secondCollection.id)
})
})
describe("POST /store/carts/:id/taxes", () => {
@@ -1363,8 +1433,13 @@ medusaIntegrationTestRunner({
rules: [
{
operator: RuleOperator.EQ,
attribute: "shipping_address.country_code",
value: "us",
attribute: "is_return",
value: "false",
},
{
operator: RuleOperator.EQ,
attribute: "enabled_in_store",
value: "true",
},
],
})

View File

@@ -5,6 +5,10 @@ import { createStep, StepResponse } from "@medusajs/workflows-sdk"
interface StepInput {
cart: CartDTO
shippingOptionsContext: {
enabled_in_store?: "true" | "false"
is_return?: "true" | "false"
}
option_ids: string[]
}
@@ -13,7 +17,7 @@ export const validateCartShippingOptionsStepId =
export const validateCartShippingOptionsStep = createStep(
validateCartShippingOptionsStepId,
async (data: StepInput, { container }) => {
const { option_ids: optionIds = [], cart } = data
const { option_ids: optionIds = [], cart, shippingOptionsContext } = data
if (!optionIds.length) {
return new StepResponse(void 0)
@@ -27,7 +31,7 @@ export const validateCartShippingOptionsStep = createStep(
await fulfillmentModule.listShippingOptionsForContext(
{
id: optionIds,
context: { ...cart },
context: shippingOptionsContext,
address: {
country_code: cart.shipping_address?.country_code,
province_code: cart.shipping_address?.province,

View File

@@ -40,6 +40,7 @@ export const addShippingMethodToWorkflow = createWorkflow(
validateCartShippingOptionsStep({
option_ids: optionIds,
cart,
shippingOptionsContext: { is_return: "false", enabled_in_store: "true" },
})
const shippingOptions = useRemoteQueryStep({

View File

@@ -46,6 +46,10 @@ export const listShippingOptionsForCartWorkflow = createWorkflow(
variables: {
id: input.sales_channel_id,
"stock_locations.fulfillment_sets.service_zones.shipping_options": {
context: {
is_return: "false",
enabled_in_store: "true",
},
filters: {
address: {
city: input.shipping_address?.city,

View File

@@ -40,6 +40,9 @@ import { storeCustomerRoutesMiddlewares } from "./store/customers/middlewares"
import { storeProductRoutesMiddlewares } from "./store/products/middlewares"
import { storeProductCategoryRoutesMiddlewares } from "./store/product-categories/middlewares"
import { storeRegionRoutesMiddlewares } from "./store/regions/middlewares"
import { storePaymentProvidersMiddlewares } from "./store/payment-providers/middlewares"
import { storePaymentCollectionsMiddlewares } from "./store/payment-collections/middlewares"
import { storeShippingOptionRoutesMiddlewares } from "./store/shipping-options/middlewares"
export const config: MiddlewaresConfig = {
routes: [
@@ -52,6 +55,9 @@ export const config: MiddlewaresConfig = {
...storeCartRoutesMiddlewares,
...storeCollectionRoutesMiddlewares,
...storeProductCategoryRoutesMiddlewares,
...storePaymentProvidersMiddlewares,
...storeShippingOptionRoutesMiddlewares,
...storePaymentCollectionsMiddlewares,
...authRoutesMiddlewares,
...adminWorkflowsExecutionsMiddlewares,
...storeRegionRoutesMiddlewares,

View File

@@ -1,39 +0,0 @@
import { createPaymentCollectionForCartWorkflow } from "@medusajs/core-flows"
import { MedusaRequest, MedusaResponse } from "../../../../../types/routing"
import { refetchCart } from "../../helpers"
import { StoreUpdateCartType } from "../../validators"
export const POST = async (
req: MedusaRequest<StoreUpdateCartType>,
res: MedusaResponse
) => {
const workflow = createPaymentCollectionForCartWorkflow(req.scope)
let cart = await refetchCart(req.params.id, req.scope, [
"id",
"currency_code",
"region_id",
"total",
])
const { errors } = await workflow.run({
input: {
cart_id: req.params.id,
region_id: cart.region_id,
currency_code: cart.currency_code,
amount: cart.total ?? 0, // TODO: This should be calculated from the cart when totals decoration is introduced
},
throwOnError: false,
})
if (Array.isArray(errors) && errors[0]) {
throw errors[0].error
}
cart = await refetchCart(
req.params.id,
req.scope,
req.remoteQueryConfig.fields
)
res.status(200).json({ cart })
}

View File

@@ -114,17 +114,6 @@ export const storeCartRoutesMiddlewares: MiddlewareRoute[] = [
),
],
},
{
method: ["POST"],
matcher: "/store/carts/:id/payment-collections",
middlewares: [
validateAndTransformBody(StoreUpdateCart),
validateAndTransformQuery(
StoreGetCartsCart,
QueryConfig.retrieveTransformQueryConfig
),
],
},
{
method: ["POST"],
matcher: "/store/carts/:id/shipping-methods",

View File

@@ -56,11 +56,11 @@ export const defaultStoreCartFields = [
"shipping_methods.tax_lines.code",
"shipping_methods.tax_lines.rate",
"shipping_methods.tax_lines.provider_id",
"shipping_methods.shipping_option_id",
"shipping_methods.amount",
"shipping_methods.adjustments.id",
"shipping_methods.adjustments.code",
"shipping_methods.adjustments.amount",
"shipping_methods.shipping_option_id",
"shipping_address.id",
"shipping_address.first_name",
"shipping_address.last_name",
@@ -86,18 +86,16 @@ export const defaultStoreCartFields = [
"region.name",
"region.currency_code",
"region.automatic_taxes",
"*region.countries",
"sales_channel_id",
// TODO: To be updated when payment sessions are introduces in the Rest API
"payment_collection.id",
"payment_collection.amount",
"payment_collection.payment_sessions",
"*payment_collection.payment_sessions",
]
const allowedFields = [...defaultStoreCartFields]
export const retrieveTransformQueryConfig = {
defaults: defaultStoreCartFields,
allowed: allowedFields,
isList: false,
}

View File

@@ -1,11 +0,0 @@
import { transformBody } from "../../../../../api/middlewares"
import { MiddlewareRoute } from "../../../../../loaders/helpers/routing/types"
import { StorePostPaymentCollectionsPaymentSessionReq } from "./validators"
export const storeCartRoutesMiddlewares: MiddlewareRoute[] = [
{
method: ["POST"],
matcher: "/store/payment-collections/:id/payment-sessions",
middlewares: [transformBody(StorePostPaymentCollectionsPaymentSessionReq)],
},
]

View File

@@ -1,20 +0,0 @@
export const defaultStorePaymentCollectionFields = [
"id",
"currency_code",
"amount",
"payment_sessions",
"payment_sessions.id",
"payment_sessions.amount",
"payment_sessions.provider_id",
]
export const defaultStorePaymentCollectionRelations = ["payment_sessions"]
export const allowedStorePaymentCollectionRelations = ["payment_sessions"]
export const retrieveTransformQueryConfig = {
defaultFields: defaultStorePaymentCollectionFields,
defaultRelations: defaultStorePaymentCollectionRelations,
allowedRelations: allowedStorePaymentCollectionRelations,
isList: false,
}

View File

@@ -1,36 +1,33 @@
import { createPaymentSessionsWorkflow } from "@medusajs/core-flows"
import { remoteQueryObjectFromString } from "@medusajs/utils"
import {
AuthenticatedMedusaRequest,
MedusaResponse,
} from "../../../../../types/routing"
import { defaultStorePaymentCollectionFields } from "./query-config"
import { StorePostPaymentCollectionsPaymentSessionReq } from "./validators"
import { StoreCreatePaymentSessionType } from "../../validators"
import { refetchPaymentCollection } from "../../helpers"
export const POST = async (
req: AuthenticatedMedusaRequest<StorePostPaymentCollectionsPaymentSessionReq>,
req: AuthenticatedMedusaRequest<StoreCreatePaymentSessionType>,
res: MedusaResponse
) => {
const { id } = req.params
const { context = {}, provider_id, data } = req.body
const collectionId = req.params.id
const { context = {}, data, provider_id } = req.body
// If the customer is logged in, we auto-assign them to the payment collection
if (req.auth?.actor_id) {
context.customer = {
...context.customer,
;(context as any).customer = {
id: req.auth.actor_id,
}
}
const workflowInput = {
payment_collection_id: id,
payment_collection_id: collectionId,
provider_id: provider_id,
data,
context,
}
const { errors } = await createPaymentSessionsWorkflow(req.scope).run({
input: workflowInput as any,
input: workflowInput,
throwOnError: false,
})
@@ -38,15 +35,11 @@ export const POST = async (
throw errors[0].error
}
const remoteQuery = req.scope.resolve("remoteQuery")
const paymentCollection = await refetchPaymentCollection(
collectionId,
req.scope,
req.remoteQueryConfig.fields
)
const query = remoteQueryObjectFromString({
entryPoint: "payment_collection",
variables: { cart: { id } },
fields: defaultStorePaymentCollectionFields,
})
const [result] = await remoteQuery(query)
res.status(200).json({ payment_collection: result })
res.status(200).json({ payment_collection: paymentCollection })
}

View File

@@ -1,7 +0,0 @@
import { PaymentProviderContext } from "@medusajs/types"
export class StorePostPaymentCollectionsPaymentSessionReq {
provider_id: string
context?: PaymentProviderContext
data?: Record<string, unknown>
}

View File

@@ -0,0 +1,10 @@
import { MedusaContainer, PaymentCollectionDTO } from "@medusajs/types"
import { refetchEntity } from "../../utils/refetch-entity"
export const refetchPaymentCollection = async (
id: string,
scope: MedusaContainer,
fields: string[]
): Promise<PaymentCollectionDTO> => {
return refetchEntity("payment_collection", id, scope, fields)
}

View File

@@ -0,0 +1,44 @@
import { MiddlewareRoute } from "../../../types/middlewares"
import { authenticate } from "../../../utils/authenticate-middleware"
import { validateAndTransformBody } from "../../utils/validate-body"
import { validateAndTransformQuery } from "../../utils/validate-query"
import * as queryConfig from "./query-config"
import {
StoreGetPaymentCollectionParams,
StoreCreatePaymentCollection,
StoreCreatePaymentSession,
} from "./validators"
export const storePaymentCollectionsMiddlewares: MiddlewareRoute[] = [
{
method: "ALL",
matcher: "/store/payment-collections*",
middlewares: [
authenticate("store", ["session", "bearer"], {
allowUnauthenticated: true,
}),
],
},
{
method: ["POST"],
matcher: "/store/payment-collections",
middlewares: [
validateAndTransformBody(StoreCreatePaymentCollection),
validateAndTransformQuery(
StoreGetPaymentCollectionParams,
queryConfig.retrievePaymentCollectionTransformQueryConfig
),
],
},
{
method: ["POST"],
matcher: "/store/payment-collections/:id/payment-sessions",
middlewares: [
validateAndTransformBody(StoreCreatePaymentSession),
validateAndTransformQuery(
StoreGetPaymentCollectionParams,
queryConfig.retrievePaymentCollectionTransformQueryConfig
),
],
},
]

View File

@@ -0,0 +1,14 @@
export const defaultPaymentCollectionFields = [
"id",
"currency_code",
"amount",
"payment_sessions",
"payment_sessions.id",
"payment_sessions.amount",
"payment_sessions.provider_id",
]
export const retrievePaymentCollectionTransformQueryConfig = {
defaults: defaultPaymentCollectionFields,
isList: false,
}

View File

@@ -0,0 +1,55 @@
import { createPaymentCollectionForCartWorkflow } from "@medusajs/core-flows"
import {
ContainerRegistrationKeys,
remoteQueryObjectFromString,
} from "@medusajs/utils"
import {
AuthenticatedMedusaRequest,
MedusaResponse,
} from "../../../types/routing"
import { StoreCreatePaymentCollectionType } from "./validators"
export const POST = async (
req: AuthenticatedMedusaRequest<StoreCreatePaymentCollectionType>,
res: MedusaResponse
) => {
const remoteQuery = req.scope.resolve(ContainerRegistrationKeys.REMOTE_QUERY)
const { cart_id } = req.body
// We can potentially refactor the workflow to behave more like an upsert and return an existing collection if there is one.
const [cartCollectionRelation] = await remoteQuery(
remoteQueryObjectFromString({
entryPoint: "cart_payment_collection",
variables: { filters: { cart_id } },
fields: req.remoteQueryConfig.fields.map(
(f) => `payment_collection.${f}`
),
})
)
let paymentCollection = cartCollectionRelation?.payment_collection
if (!paymentCollection) {
const { errors } = await createPaymentCollectionForCartWorkflow(
req.scope
).run({
input: req.body,
})
if (Array.isArray(errors) && errors[0]) {
throw errors[0].error
}
const [cartCollectionRelation] = await remoteQuery(
remoteQueryObjectFromString({
entryPoint: "cart_payment_collection",
variables: { filters: { cart_id } },
fields: req.remoteQueryConfig.fields.map(
(f) => `payment_collection.${f}`
),
})
)
paymentCollection = cartCollectionRelation?.payment_collection
}
res.status(200).json({ payment_collection: paymentCollection })
}

View File

@@ -0,0 +1,30 @@
import { z } from "zod"
import { createSelectParams } from "../../utils/validators"
export type StoreGetPaymentCollectionParamsType = z.infer<
typeof StoreGetPaymentCollectionParams
>
export const StoreGetPaymentCollectionParams = createSelectParams()
export type StoreCreatePaymentSessionType = z.infer<
typeof StoreCreatePaymentSession
>
export const StoreCreatePaymentSession = z
.object({
provider_id: z.string(),
context: z.record(z.unknown()).optional(),
data: z.record(z.unknown()).optional(),
})
.strict()
export type StoreCreatePaymentCollectionType = z.infer<
typeof StoreCreatePaymentCollection
>
export const StoreCreatePaymentCollection = z
.object({
cart_id: z.string(),
region_id: z.string(),
currency_code: z.string(),
amount: z.number(),
})
.strict()

View File

@@ -0,0 +1,17 @@
import { MiddlewareRoute } from "../../../types/middlewares"
import { validateAndTransformQuery } from "../../utils/validate-query"
import * as queryConfig from "./query-config"
import { StoreGetPaymentProvidersParams } from "./validators"
export const storePaymentProvidersMiddlewares: MiddlewareRoute[] = [
{
method: ["GET"],
matcher: "/store/payment-providers",
middlewares: [
validateAndTransformQuery(
StoreGetPaymentProvidersParams,
queryConfig.listTransformPaymentProvidersQueryConfig
),
],
},
]

View File

@@ -0,0 +1,6 @@
export const defaultAdminPaymentProviderFields = ["id", "is_enabled"]
export const listTransformPaymentProvidersQueryConfig = {
defaults: defaultAdminPaymentProviderFields,
isList: true,
}

View File

@@ -0,0 +1,50 @@
import {
AuthenticatedMedusaRequest,
MedusaResponse,
} from "../../../types/routing"
import {
ContainerRegistrationKeys,
MedusaError,
remoteQueryObjectFromString,
} from "@medusajs/utils"
import { StoreGetPaymentProvidersParamsType } from "./validators"
// TODO: Add more fields to provider, such as default name and maybe logo.
export const GET = async (
req: AuthenticatedMedusaRequest<StoreGetPaymentProvidersParamsType>,
res: MedusaResponse
) => {
if (!req.filterableFields.region_id) {
throw new MedusaError(
MedusaError.Types.NOT_ALLOWED,
"You must provide the region_id to list payment providers"
)
}
const remoteQuery = req.scope.resolve(ContainerRegistrationKeys.REMOTE_QUERY)
const queryObject = remoteQueryObjectFromString({
entryPoint: "region_payment_provider",
variables: {
filters: {
region_id: req.filterableFields.region_id,
},
...req.remoteQueryConfig.pagination,
},
fields: req.remoteQueryConfig.fields.map((f) => `payment_provider.${f}`),
})
const { rows: regionPaymentProvidersRelation, metadata } = await remoteQuery(
queryObject
)
const paymentProviders = regionPaymentProvidersRelation.map(
(relation) => relation.payment_provider
)
res.json({
payment_providers: paymentProviders,
count: metadata.count,
offset: metadata.skip,
limit: metadata.take,
})
}

View File

@@ -0,0 +1,18 @@
import { z } from "zod"
import { createFindParams } from "../../utils/validators"
export type StoreGetPaymentProvidersParamsType = z.infer<
typeof StoreGetPaymentProvidersParams
>
export const StoreGetPaymentProvidersParams = createFindParams({
limit: 20,
offset: 0,
}).merge(
z.object({
region_id: z.string(),
id: z.union([z.string(), z.array(z.string())]).optional(),
is_enabled: z.boolean().optional(),
$and: z.lazy(() => StoreGetPaymentProvidersParams.array()).optional(),
$or: z.lazy(() => StoreGetPaymentProvidersParams.array()).optional(),
})
)

View File

@@ -1,9 +1,17 @@
import { MiddlewareRoute } from "../../../loaders/helpers/routing/types"
import { validateAndTransformQuery } from "../../utils/validate-query"
import { listTransformQueryConfig } from "./query-config"
import { StoreGetShippingOptions } from "./validators"
export const storeShippingOptionRoutesMiddlewares: MiddlewareRoute[] = [
{
method: ["GET"],
matcher: "/store/shipping-options/:cart_id",
middlewares: [],
matcher: "/store/shipping-options",
middlewares: [
validateAndTransformQuery(
StoreGetShippingOptions,
listTransformQueryConfig
),
],
},
]

View File

@@ -1,10 +1,17 @@
import { listShippingOptionsForCartWorkflow } from "@medusajs/core-flows"
import { ModuleRegistrationName } from "@medusajs/modules-sdk"
import { ICartModuleService } from "@medusajs/types"
import { MedusaRequest, MedusaResponse } from "../../../../types/routing"
import { MedusaRequest, MedusaResponse } from "../../../types/routing"
import { MedusaError } from "@medusajs/utils"
export const GET = async (req: MedusaRequest, res: MedusaResponse) => {
const { cart_id } = req.params
const { cart_id } = req.filterableFields as { cart_id: string }
if (!cart_id) {
throw new MedusaError(
MedusaError.Types.NOT_ALLOWED,
"You must provide the cart_id to list shipping options"
)
}
const cartService = req.scope.resolve<ICartModuleService>(
ModuleRegistrationName.CART

View File

@@ -0,0 +1,16 @@
import { z } from "zod"
import { createFindParams } from "../../utils/validators"
export type StoreGetShippingOptionsType = z.infer<
typeof StoreGetShippingOptions
>
export const StoreGetShippingOptions = createFindParams({
limit: 20,
offset: 0,
}).merge(
z.object({
cart_id: z.string(),
$and: z.lazy(() => StoreGetShippingOptions.array()).optional(),
$or: z.lazy(() => StoreGetShippingOptions.array()).optional(),
})
)