feat: Refresh payment collection + delete session (#6594)

### What
Add workflow for refreshing a payment collection. 

The idea is that on all cart updates, we want two things to happen (in the context of payments) 1. the currently active payment sessions should be destroyed, and 2. the payment collection should be updated with the new cart total.

We do this to ensure that we always collect the correct payment amount. 

From a customer perspective, this would mean that every time something on the cart is updated, the customer would need to enter their payment details anew. 

To me, this is a good tradeoff to avoid inconsistencies with payment collection.

Additionally, I updated the Payment Module interface with `upsert` and `updated` following our established convention.

### Note
This PR depends on a fix to the `remoteJoiner` that @carlos-r-l-rodrigues is working on.

Update: Fix merged in #6602 

Co-authored-by: Adrien de Peretti <25098370+adrien2p@users.noreply.github.com>
This commit is contained in:
Oli Juhl
2024-03-07 14:32:20 +01:00
committed by GitHub
parent a516e7bcba
commit 8c57e61cb8
23 changed files with 763 additions and 173 deletions

View File

@@ -6,10 +6,12 @@ import {
deleteLineItemsWorkflow,
findOrCreateCustomerStepId,
linkCartAndPaymentCollectionsStepId,
refreshPaymentCollectionForCartWorkflow,
updateLineItemInCartWorkflow,
updateLineItemsStepId,
updatePaymentCollectionStepId,
} from "@medusajs/core-flows"
import { ModuleRegistrationName } from "@medusajs/modules-sdk"
import { ModuleRegistrationName, Modules } from "@medusajs/modules-sdk"
import {
ICartModuleService,
ICustomerModuleService,
@@ -19,8 +21,8 @@ import {
IRegionModuleService,
ISalesChannelModuleService,
} from "@medusajs/types"
import adminSeeder from "../../../../helpers/admin-seeder"
import { medusaIntegrationTestRunner } from "medusa-test-utils"
import adminSeeder from "../../../../helpers/admin-seeder"
jest.setTimeout(200000)
@@ -664,89 +666,12 @@ medusaIntegrationTestRunner({
expect(updatedItem).not.toBeUndefined()
})
})
})
describe("createPaymentCollectionForCart", () => {
it("should create a payment collection and link it to cart", async () => {
const region = await regionModuleService.create({
name: "US",
currency_code: "usd",
})
const cart = await cartModuleService.create({
currency_code: "usd",
region_id: region.id,
items: [
{
quantity: 1,
unit_price: 5000,
title: "Test item",
},
],
})
await createPaymentCollectionForCartWorkflow(appContainer).run({
input: {
cart_id: cart.id,
region_id: region.id,
currency_code: "usd",
amount: 5000,
},
throwOnError: false,
})
const result = await remoteQuery(
{
cart: {
fields: ["id"],
payment_collection: {
fields: ["id", "amount", "currency_code"],
},
},
},
{
cart: {
id: cart.id,
},
}
)
expect(result).toEqual([
expect.objectContaining({
id: cart.id,
payment_collection: expect.objectContaining({
amount: 5000,
currency_code: "usd",
}),
}),
])
})
describe("compensation", () => {
it("should dismiss cart <> payment collection link and delete created payment collection", async () => {
const workflow =
createPaymentCollectionForCartWorkflow(appContainer)
workflow.appendAction(
"throw",
linkCartAndPaymentCollectionsStepId,
{
invoke: async function failStep() {
throw new Error(
`Failed to do something after linking cart and payment collection`
)
},
}
)
const region = await regionModuleService.create({
name: "US",
currency_code: "usd",
})
describe("createPaymentCollectionForCart", () => {
it("should create a payment collection and link it to cart", async () => {
const cart = await cartModuleService.create({
currency_code: "usd",
region_id: region.id,
currency_code: "dkk",
region_id: defaultRegion.id,
items: [
{
quantity: 1,
@@ -756,27 +681,17 @@ medusaIntegrationTestRunner({
],
})
const { errors } = await workflow.run({
await createPaymentCollectionForCartWorkflow(appContainer).run({
input: {
cart_id: cart.id,
region_id: region.id,
currency_code: "usd",
region_id: defaultRegion.id,
currency_code: "dkk",
amount: 5000,
},
throwOnError: false,
})
expect(errors).toEqual([
{
action: "throw",
handlerType: "invoke",
error: new Error(
`Failed to do something after linking cart and payment collection`
),
},
])
const carts = await remoteQuery(
const result = await remoteQuery(
{
cart: {
fields: ["id"],
@@ -792,19 +707,274 @@ medusaIntegrationTestRunner({
}
)
const payCols = await remoteQuery({
payment_collection: {
fields: ["id"],
expect(result).toEqual([
expect.objectContaining({
id: cart.id,
payment_collection: expect.objectContaining({
amount: 5000,
currency_code: "dkk",
}),
}),
])
})
describe("compensation", () => {
it("should dismiss cart <> payment collection link and delete created payment collection", async () => {
const workflow =
createPaymentCollectionForCartWorkflow(appContainer)
workflow.appendAction(
"throw",
linkCartAndPaymentCollectionsStepId,
{
invoke: async function failStep() {
throw new Error(
`Failed to do something after linking cart and payment collection`
)
},
}
)
const region = await regionModuleService.create({
name: "US",
currency_code: "usd",
})
const cart = await cartModuleService.create({
currency_code: "usd",
region_id: region.id,
items: [
{
quantity: 1,
unit_price: 5000,
title: "Test item",
},
],
})
const { errors } = await workflow.run({
input: {
cart_id: cart.id,
region_id: region.id,
currency_code: "usd",
amount: 5000,
},
throwOnError: false,
})
expect(errors).toEqual([
{
action: "throw",
handlerType: "invoke",
error: new Error(
`Failed to do something after linking cart and payment collection`
),
},
])
const carts = await remoteQuery(
{
cart: {
fields: ["id"],
payment_collection: {
fields: ["id", "amount", "currency_code"],
},
},
},
{
cart: {
id: cart.id,
},
}
)
const payCols = await remoteQuery({
payment_collection: {
fields: ["id"],
},
})
expect(carts).toEqual([
expect.objectContaining({
id: cart.id,
payment_collection: undefined,
}),
])
expect(payCols.length).toEqual(0)
})
})
})
})
describe("refreshPaymentCollectionForCart", () => {
it("should refresh a payment collection for a cart", async () => {
const cart = await cartModuleService.create({
currency_code: "dkk",
region_id: defaultRegion.id,
items: [
{
quantity: 1,
unit_price: 5000,
title: "Test item",
},
],
})
const paymentCollection =
await paymentModule.createPaymentCollections({
amount: 5000,
currency_code: "dkk",
region_id: defaultRegion.id,
})
const paymentSession = await paymentModule.createPaymentSession(
paymentCollection.id,
{
amount: 5000,
currency_code: "dkk",
data: {},
provider_id: "pp_system_default",
}
)
await remoteLink.create([
{
[Modules.CART]: {
cart_id: cart.id,
},
[Modules.PAYMENT]: {
payment_collection_id: paymentCollection.id,
},
},
])
await refreshPaymentCollectionForCartWorkflow(appContainer).run({
input: {
cart_id: cart.id,
},
throwOnError: false,
})
const updatedPaymentCollection =
await paymentModule.retrievePaymentCollection(paymentCollection.id)
expect(updatedPaymentCollection).toEqual(
expect.objectContaining({
id: paymentCollection.id,
amount: 4242,
})
)
const sessionShouldNotExist = await paymentModule.listPaymentSessions(
{ id: paymentSession.id },
{ withDeleted: true }
)
expect(sessionShouldNotExist).toHaveLength(0)
})
describe("compensation", () => {
it("should revert payment collection amount and create a new payment session", async () => {
const region = await regionModuleService.create({
name: "US",
currency_code: "usd",
})
const testCart = await cartModuleService.create({
currency_code: "usd",
region_id: region.id,
items: [
{
quantity: 1,
unit_price: 5000,
title: "Test item",
},
],
})
const paymentCollection =
await paymentModule.createPaymentCollections({
amount: 5000,
currency_code: "dkk",
region_id: defaultRegion.id,
})
const paymentSession = await paymentModule.createPaymentSession(
paymentCollection.id,
{
amount: 5000,
currency_code: "dkk",
data: {},
provider_id: "pp_system_default",
}
)
await remoteLink.create([
{
[Modules.CART]: {
cart_id: testCart.id,
},
[Modules.PAYMENT]: {
payment_collection_id: paymentCollection.id,
},
},
])
const workflow =
refreshPaymentCollectionForCartWorkflow(appContainer)
workflow.appendAction("throw", updatePaymentCollectionStepId, {
invoke: async function failStep() {
throw new Error(
`Failed to do something after updating payment collections`
)
},
})
expect(carts).toEqual([
expect.objectContaining({
id: cart.id,
payment_collection: undefined,
}),
const { errors } = await workflow.run({
input: {
cart_id: testCart.id,
},
throwOnError: false,
})
expect(errors).toEqual([
{
action: "throw",
handlerType: "invoke",
error: new Error(
`Failed to do something after updating payment collections`
),
},
])
expect(payCols.length).toEqual(0)
const updatedPaymentCollection =
await paymentModule.retrievePaymentCollection(
paymentCollection.id,
{
relations: ["payment_sessions"],
}
)
const sessions = await paymentModule.listPaymentSessions({
payment_collection_id: paymentCollection.id,
})
expect(sessions).toHaveLength(1)
expect(sessions[0].id).not.toEqual(paymentSession.id)
expect(sessions[0]).toEqual(
expect.objectContaining({
id: expect.any(String),
amount: 5000,
currency_code: "dkk",
})
)
expect(updatedPaymentCollection).toEqual(
expect.objectContaining({
id: paymentCollection.id,
amount: 5000,
})
)
})
})
})

View File

@@ -5,7 +5,7 @@
"license": "MIT",
"private": true,
"scripts": {
"test:integration": "node --expose-gc ./../../node_modules/.bin/jest --ci --silent=true -i --detectOpenHandles --logHeapUsage --forceExit",
"test:integration": "node --expose-gc ./../../node_modules/.bin/jest --ci --silent=false -i --detectOpenHandles --logHeapUsage --forceExit",
"build": "babel src -d dist --extensions \".ts,.js\""
},
"dependencies": {

View File

@@ -0,0 +1,26 @@
import { remoteQueryObjectFromString } from "@medusajs/utils"
import { StepResponse, createStep } from "@medusajs/workflows-sdk"
interface StepInput {
entry_point: string
fields: string[]
variables?: Record<string, any>
}
export const useRemoteQueryStepId = "use-remote-query"
export const useRemoteQueryStep = createStep(
useRemoteQueryStepId,
async (data: StepInput, { container }) => {
const query = container.resolve("remoteQuery")
const queryObject = remoteQueryObjectFromString({
entryPoint: data.entry_point,
fields: data.fields,
variables: data.variables,
})
const result = await query(queryObject)
return new StepResponse(result)
}
)

View File

@@ -1,6 +1,7 @@
export * from "./add-to-cart"
export * from "./create-carts"
export * from "./create-payment-collection-for-cart"
export * from "./refresh-payment-collection"
export * from "./update-cart"
export * from "./update-cart-promotions"
export * from "./update-line-item-in-cart"

View File

@@ -0,0 +1,70 @@
import {
StepResponse,
WorkflowData,
createStep,
createWorkflow,
} from "@medusajs/workflows-sdk"
import { useRemoteQueryStep } from "../../../common/steps/use-remote-query"
import {
deletePaymentSessionStep,
updatePaymentCollectionStep,
} from "../../payment-collection"
type WorklowInput = {
cart_id: string
}
interface StepInput {
cart_id: string
}
// We export a step running the workflow too, so that we can use it as a subworkflow e.g. in the update cart workflows
export const refreshPaymentCollectionForCartStepId =
"refresh-payment-collection-for-cart"
export const refreshPaymentCollectionForCartStep = createStep(
refreshPaymentCollectionForCartStepId,
async (data: StepInput, { container }) => {
await refreshPaymentCollectionForCartWorkflow(container).run({
input: {
cart_id: data.cart_id,
},
})
return new StepResponse(null)
}
)
export const refreshPaymentCollectionForCartWorkflowId =
"refresh-payment-collection-for-cart"
export const refreshPaymentCollectionForCartWorkflow = createWorkflow(
refreshPaymentCollectionForCartWorkflowId,
(input: WorkflowData<WorklowInput>): WorkflowData<void> => {
const carts = useRemoteQueryStep({
entry_point: "cart",
fields: [
"id",
"total",
"currency_code",
"payment_collection.id",
"payment_collection.payment_sessions.id",
],
variables: { id: input.cart_id },
})
deletePaymentSessionStep({
payment_session_id: carts[0].payment_collection.payment_sessions?.[0].id,
})
// TODO: Temporary fixed cart total, so we can test the workflow.
// This will be removed when the totals utilities are built.
const cartTotal = 4242
updatePaymentCollectionStep({
selector: { id: carts[0].payment_collection.id },
update: {
amount: cartTotal,
currency_code: carts[0].currency_code,
},
})
}
)

View File

@@ -14,6 +14,7 @@ import {
updateCartsStep,
} from "../steps"
import { refreshCartPromotionsStep } from "../steps/refresh-cart-promotions"
import { refreshPaymentCollectionForCartStep } from "./refresh-payment-collection"
export const updateCartWorkflowId = "update-cart"
export const updateCartWorkflow = createWorkflow(
@@ -68,6 +69,10 @@ export const updateCartWorkflow = createWorkflow(
action: PromotionActions.REPLACE,
})
refreshPaymentCollectionForCartStep({
cart_id: input.id,
})
const retrieveCartInput = {
id: input.id,
config: {

View File

@@ -0,0 +1,46 @@
import { ModuleRegistrationName } from "@medusajs/modules-sdk"
import { IPaymentModuleService } from "@medusajs/types"
import { StepResponse, createStep } from "@medusajs/workflows-sdk"
interface StepInput {
payment_session_id?: string
}
export const deletePaymentSessionStepId = "delete-payment-session"
export const deletePaymentSessionStep = createStep(
deletePaymentSessionStepId,
async (input: StepInput, { container }) => {
const service = container.resolve<IPaymentModuleService>(
ModuleRegistrationName.PAYMENT
)
if (!input.payment_session_id) {
return new StepResponse(void 0, null)
}
const [session] = await service.listPaymentSessions({
id: input.payment_session_id,
})
await service.deletePaymentSession(input.payment_session_id)
return new StepResponse(input.payment_session_id, session)
},
async (input, { container }) => {
const service = container.resolve<IPaymentModuleService>(
ModuleRegistrationName.PAYMENT
)
if (!input || !input.payment_collection) {
return
}
await service.createPaymentSession(input.payment_collection.id, {
provider_id: input.provider_id,
currency_code: input.currency_code,
amount: input.amount,
data: input.data ?? {},
context: input.context,
})
}
)

View File

@@ -1,3 +1,5 @@
export * from "./create-payment-session"
export * from "./delete-payment-session"
export * from "./retrieve-payment-collection"
export * from "./update-payment-collection"

View File

@@ -0,0 +1,59 @@
import { ModuleRegistrationName } from "@medusajs/modules-sdk"
import {
FilterablePaymentCollectionProps,
IPaymentModuleService,
PaymentCollectionUpdatableFields,
} from "@medusajs/types"
import { getSelectsAndRelationsFromObjectArray } from "@medusajs/utils"
import { StepResponse, createStep } from "@medusajs/workflows-sdk"
interface StepInput {
selector: FilterablePaymentCollectionProps
update: PaymentCollectionUpdatableFields
}
export const updatePaymentCollectionStepId = "update-payment-collection"
export const updatePaymentCollectionStep = createStep(
updatePaymentCollectionStepId,
async (data: StepInput, { container }) => {
const paymentModuleService = container.resolve<IPaymentModuleService>(
ModuleRegistrationName.PAYMENT
)
const { selects, relations } = getSelectsAndRelationsFromObjectArray([
data.update,
])
const prevData = await paymentModuleService.listPaymentCollections(
data.selector,
{
select: selects,
relations,
}
)
const updated = await paymentModuleService.updatePaymentCollections(
data.selector,
data.update
)
return new StepResponse(updated, prevData)
},
async (prevData, { container }) => {
if (!prevData) {
return
}
const paymentModuleService = container.resolve<IPaymentModuleService>(
ModuleRegistrationName.PAYMENT
)
await paymentModuleService.upsertPaymentCollections(
prevData.map((pc) => ({
id: pc.id,
amount: pc.amount,
currency_code: pc.currency_code,
metadata: pc.metadata,
}))
)
}
)

View File

@@ -1,11 +1,10 @@
import { ContainerRegistrationKeys, ModulesSdkUtils } from "@medusajs/utils"
import { initModules, InitModulesOptions } from "./init-modules"
import {
MedusaAppOutput,
MedusaModuleConfig,
ModulesDefinition
} from "@medusajs/modules-sdk"
import { getDatabaseURL, getMikroOrmWrapper, TestDatabase } from "./database"
import { ContainerRegistrationKeys, ModulesSdkUtils } from "@medusajs/utils"
import { TestDatabase, getDatabaseURL, getMikroOrmWrapper } from "./database"
import { InitModulesOptions, initModules } from "./init-modules"
import { MockEventBusService } from "."

View File

@@ -277,8 +277,7 @@ moduleIntegrationTestRunner({
describe("update", () => {
it("should update a Payment Collection", async () => {
await service.updatePaymentCollections({
id: "pay-col-id-2",
await service.updatePaymentCollections("pay-col-id-2", {
currency_code: "eur",
region_id: "reg-2",
})

View File

@@ -1,7 +1,12 @@
import { Modules } from "@medusajs/modules-sdk"
import { ModuleJoinerConfig } from "@medusajs/types"
import { MapToConfig } from "@medusajs/utils"
import { Payment, PaymentCollection, PaymentProvider } from "@models"
import {
Payment,
PaymentCollection,
PaymentProvider,
PaymentSession,
} from "@models"
export const LinkableKeys = {
payment_id: Payment.name,
@@ -37,6 +42,13 @@ export const joinerConfig: ModuleJoinerConfig = {
entity: PaymentCollection.name,
},
},
{
name: ["payment_session", "payment_sessions"],
args: {
entity: PaymentSession.name,
methodSuffix: "PaymentSessions",
},
},
{
name: ["payment_provider", "payment_providers"],
args: {

View File

@@ -28,6 +28,7 @@ export class Migration20240225134525 extends Migration {
ALTER TABLE IF EXISTS "payment_session" ADD COLUMN IF NOT EXISTS "payment_authorized_at" TIMESTAMPTZ NULL;
ALTER TABLE IF EXISTS "payment_session" ADD COLUMN IF NOT EXISTS "raw_amount" JSONB NOT NULL;
ALTER TABLE IF EXISTS "payment_session" ADD COLUMN IF NOT EXISTS "deleted_at" TIMESTAMPTZ NULL;
ALTER TABLE IF EXISTS "payment_session" ADD COLUMN IF NOT EXISTS "context" JSONB NULL;
ALTER TABLE IF EXISTS "payment" ADD COLUMN IF NOT EXISTS "deleted_at" TIMESTAMPTZ NULL;
ALTER TABLE IF EXISTS "payment" ADD COLUMN IF NOT EXISTS "payment_collection_id" TEXT NOT NULL;
@@ -161,6 +162,7 @@ export class Migration20240225134525 extends Migration {
"raw_amount" JSONB NOT NULL,
"provider_id" TEXT NOT NULL,
"data" JSONB NOT NULL,
"context" JSONB NULL,
"status" TEXT CHECK ("status" IN ('authorized', 'pending', 'requires_more', 'error', 'canceled')) NOT NULL DEFAULT 'pending',
"authorized_at" TIMESTAMPTZ NULL,
"payment_collection_id" TEXT NOT NULL,

View File

@@ -43,6 +43,9 @@ export default class PaymentSession {
@Property({ columnType: "jsonb" })
data: Record<string, unknown> = {}
@Property({ columnType: "jsonb", nullable: true })
context: Record<string, unknown> | null
@Enum({
items: () => PaymentSessionStatus,
})

View File

@@ -6,6 +6,7 @@ import {
CreatePaymentSessionDTO,
CreateRefundDTO,
DAL,
FilterablePaymentCollectionProps,
FilterablePaymentProviderProps,
FindConfig,
InternalModuleDeclaration,
@@ -13,6 +14,7 @@ import {
ModuleJoinerConfig,
ModulesSdkTypes,
PaymentCollectionDTO,
PaymentCollectionUpdatableFields,
PaymentDTO,
PaymentProviderDTO,
PaymentSessionDTO,
@@ -22,14 +24,17 @@ import {
UpdatePaymentCollectionDTO,
UpdatePaymentDTO,
UpdatePaymentSessionDTO,
UpsertPaymentCollectionDTO,
} from "@medusajs/types"
import {
InjectManager,
InjectTransactionManager,
isString,
MedusaContext,
MedusaError,
ModulesSdkUtils,
PaymentActions,
promiseAll,
} from "@medusajs/utils"
import {
Capture,
@@ -127,15 +132,14 @@ export default class PaymentModuleService<
data: CreatePaymentCollectionDTO[],
sharedContext?: Context
): Promise<PaymentCollectionDTO[]>
@InjectTransactionManager("baseRepository_")
@InjectManager("baseRepository_")
async createPaymentCollections(
data: CreatePaymentCollectionDTO | CreatePaymentCollectionDTO[],
@MedusaContext() sharedContext?: Context
): Promise<PaymentCollectionDTO | PaymentCollectionDTO[]> {
const input = Array.isArray(data) ? data : [data]
const collections = await this.paymentCollectionService_.create(
const collections = await this.createPaymentCollections_(
input,
sharedContext
)
@@ -148,23 +152,54 @@ export default class PaymentModuleService<
)
}
@InjectTransactionManager("baseRepository_")
async createPaymentCollections_(
data: CreatePaymentCollectionDTO[],
@MedusaContext() sharedContext?: Context
): Promise<PaymentCollection[]> {
return this.paymentCollectionService_.create(data, sharedContext)
}
updatePaymentCollections(
data: UpdatePaymentCollectionDTO[],
sharedContext?: Context
): Promise<PaymentCollectionDTO[]>
updatePaymentCollections(
data: UpdatePaymentCollectionDTO,
paymentCollectionId: string,
data: PaymentCollectionUpdatableFields,
sharedContext?: Context
): Promise<PaymentCollectionDTO>
@InjectTransactionManager("baseRepository_")
async updatePaymentCollections(
data: UpdatePaymentCollectionDTO | UpdatePaymentCollectionDTO[],
updatePaymentCollections(
selector: FilterablePaymentCollectionProps,
data: PaymentCollectionUpdatableFields,
sharedContext?: Context
): Promise<PaymentCollectionDTO[]>
@InjectManager("baseRepository_")
async updatePaymentCollections(
idOrSelector: string | FilterablePaymentCollectionProps,
data: PaymentCollectionUpdatableFields,
@MedusaContext() sharedContext?: Context
): Promise<PaymentCollectionDTO | PaymentCollectionDTO[]> {
const input = Array.isArray(data) ? data : [data]
const result = await this.paymentCollectionService_.update(
input,
let updateData: UpdatePaymentCollectionDTO[] = []
if (isString(idOrSelector)) {
updateData = [
{
id: idOrSelector,
...data,
},
]
} else {
const collections = await this.paymentCollectionService_.list(
idOrSelector,
{},
sharedContext
)
updateData = collections.map((c) => ({
id: c.id,
...data,
}))
}
const result = await this.updatePaymentCollections_(
updateData,
sharedContext
)
@@ -176,6 +211,52 @@ export default class PaymentModuleService<
)
}
@InjectTransactionManager("baseRepository_")
async updatePaymentCollections_(
data: UpdatePaymentCollectionDTO[],
@MedusaContext() sharedContext?: Context
): Promise<PaymentCollection[]> {
return await this.paymentCollectionService_.update(data, sharedContext)
}
upsertPaymentCollections(
data: UpsertPaymentCollectionDTO[],
sharedContext?: Context
): Promise<PaymentCollectionDTO[]>
upsertPaymentCollections(
data: UpsertPaymentCollectionDTO,
sharedContext?: Context
): Promise<PaymentCollectionDTO>
@InjectTransactionManager("baseRepository_")
async upsertPaymentCollections(
data: UpsertPaymentCollectionDTO | UpsertPaymentCollectionDTO[],
@MedusaContext() sharedContext?: Context
): Promise<PaymentCollectionDTO | PaymentCollectionDTO[]> {
const input = Array.isArray(data) ? data : [data]
const forUpdate = input.filter(
(collection): collection is UpdatePaymentCollectionDTO => !!collection.id
)
const forCreate = input.filter(
(collection): collection is CreatePaymentCollectionDTO => !collection.id
)
const operations: Promise<PaymentCollection[]>[] = []
if (forCreate.length) {
operations.push(this.createPaymentCollections_(forCreate, sharedContext))
}
if (forUpdate.length) {
operations.push(this.updatePaymentCollections_(forUpdate, sharedContext))
}
const result = (await promiseAll(operations)).flat()
return await this.baseRepository_.serialize<
PaymentCollectionDTO[] | PaymentCollectionDTO
>(Array.isArray(data) ? result : result[0])
}
completePaymentCollections(
paymentCollectionId: string,
sharedContext?: Context

View File

@@ -114,7 +114,7 @@ export interface PaymentCollectionDTO {
/**
* Holds custom data in key-value pairs
*/
metadata?: Record<string, unknown> | null
metadata?: Record<string, unknown>
/**
* The status of the payment collection
@@ -417,6 +417,11 @@ export interface PaymentSessionDTO {
*/
data: Record<string, unknown>
/**
* Payment session context
*/
context?: Record<string, unknown>
/**
* The status of the payment session
*/

View File

@@ -1,4 +1,3 @@
import { PaymentCollectionStatus } from "./common"
import { PaymentProviderContext } from "./provider"
/**
@@ -35,21 +34,24 @@ export interface UpdatePaymentCollectionDTO
* The ID of the payment collection.
*/
id: string
}
/**
* The authorized amount of the payment collection.
*/
authorized_amount?: number
export interface UpsertPaymentCollectionDTO {
id?: string
region_id?: string
currency_code?: string
amount?: number
metadata?: Record<string, unknown>
}
/**
* The refunded amount of the payment collection.
*/
refunded_amount?: number
/**
* The status of the payment collection.
*/
status?: PaymentCollectionStatus
/**
* The attributes to update in the payment collection.
*/
export interface PaymentCollectionUpdatableFields {
region_id?: string
currency_code?: string
amount?: number
metadata?: Record<string, unknown>
}
/**

View File

@@ -20,10 +20,11 @@ import {
CreatePaymentCollectionDTO,
CreatePaymentSessionDTO,
CreateRefundDTO,
PaymentCollectionUpdatableFields,
ProviderWebhookPayload,
UpdatePaymentCollectionDTO,
UpdatePaymentDTO,
UpdatePaymentSessionDTO,
UpsertPaymentCollectionDTO,
} from "./mutations"
/**
@@ -164,33 +165,23 @@ export interface IPaymentModuleService extends IModuleService {
sharedContext?: Context
): Promise<[PaymentCollectionDTO[], number]>
/**
* This method updates existing payment collections.
*
* @param {UpdatePaymentCollectionDTO[]} data - The attributes to update in payment collections.
* @param {Context} sharedContext - A context used to share resources, such as transaction manager, between the application and the module.
* @returns {Promise<PaymentCollectionDTO[]>} The updated payment collections.
*
* @example
* {example-code}
*/
updatePaymentCollections(
data: UpdatePaymentCollectionDTO[],
paymentCollectionId: string,
data: PaymentCollectionUpdatableFields,
sharedContext?: Context
): Promise<PaymentCollectionDTO>
updatePaymentCollections(
selector: FilterablePaymentCollectionProps,
data: PaymentCollectionUpdatableFields,
sharedContext?: Context
): Promise<PaymentCollectionDTO[]>
/**
* This method updates an existing payment collection.
*
* @param {UpdatePaymentCollectionDTO} data - The attributes to update in a payment collection.
* @param {Context} sharedContext - A context used to share resources, such as transaction manager, between the application and the module.
* @returns {Promise<PaymentCollectionDTO[]>} The updated payment collection.
*
* @example
* {example-code}
*/
updatePaymentCollections(
data: UpdatePaymentCollectionDTO,
upsertPaymentCollections(
data: UpsertPaymentCollectionDTO[],
sharedContext?: Context
): Promise<PaymentCollectionDTO[]>
upsertPaymentCollections(
data: UpsertPaymentCollectionDTO,
sharedContext?: Context
): Promise<PaymentCollectionDTO>

View File

@@ -0,0 +1,74 @@
import { deepCopy } from "../deep-copy"
class TestA {
prop1: any
prop2: any
constructor(prop1: any, prop2: any) {
this.prop1 = prop1
this.prop2 = prop2
}
}
class TestWrapper {
prop1: any
prop2: any
constructor(prop1: any, prop2: any) {
this.prop1 = prop1
this.prop2 = prop2
}
factory() {
return new TestA(deepCopy(this.prop1), deepCopy(this.prop2))
}
}
class TestWrapperWithoutDeepCopy {
prop1: any
prop2: any
constructor(prop1: any, prop2: any) {
this.prop1 = prop1
this.prop2 = prop2
}
factory() {
return new TestA(this.prop1, this.prop2)
}
}
describe("deepCopy", () => {
it("should deep copy an object", () => {
const prop1 = {
prop1: 1,
}
const prop2 = {
prop1: 3,
}
const wrapperWithoutDeepCopy = new TestWrapperWithoutDeepCopy(prop1, prop2)
let factory1 = wrapperWithoutDeepCopy.factory()
let factory2 = wrapperWithoutDeepCopy.factory()
factory1.prop1.prop1 = 2
expect(wrapperWithoutDeepCopy.prop1).toEqual({ prop1: 2 })
expect(factory1.prop1).toEqual({ prop1: 2 })
expect(factory2.prop1).toEqual({ prop1: 2 })
prop1.prop1 = 4
prop2.prop1 = 4
const wrapper = new TestWrapper(prop1, prop2)
factory1 = wrapper.factory()
factory2 = wrapper.factory()
factory1.prop1.prop1 = 2
expect(wrapper.prop1).toEqual({ prop1: 4 })
expect(factory1.prop1).toEqual({ prop1: 2 })
expect(factory2.prop1).toEqual({ prop1: 4 })
})
})

View File

@@ -0,0 +1,40 @@
import { isObject } from "./is-object"
/**
* In most casees, JSON.parse(JSON.stringify(obj)) is enough to deep copy an object.
* But in some cases, it's not enough. For example, if the object contains a function or a proxy, it will be lost after JSON.parse(JSON.stringify(obj)).
* Furthermore, structuredClone is not present in all environments, such as with jest so we need to use a custom deepCopy function.
*
* @param obj
*/
export function deepCopy<T extends Record<any, any> = Record<any, any>>(
obj: T | T[]
): T | T[] {
if (typeof structuredClone != "undefined") {
return structuredClone(obj)
}
if (obj === null || typeof obj !== "object") {
return obj
}
if (Array.isArray(obj)) {
const copy: any[] = []
for (let i = 0; i < obj.length; i++) {
copy[i] = deepCopy(obj[i])
}
return copy
}
if (isObject(obj)) {
const copy: Record<any, any> = {}
for (let attr in obj) {
if (obj.hasOwnProperty(attr)) {
copy[attr] = deepCopy(obj[attr])
}
}
return copy
}
return obj
}

View File

@@ -8,6 +8,7 @@ export * from "./create-container-like"
export * from "./create-psql-index-helper"
export * from "./deduplicate"
export * from "./deep-equal-obj"
export * from "./deep-copy"
export * from "./errors"
export * from "./generate-entity-id"
export * from "./generate-linkable-keys-map"

View File

@@ -149,7 +149,7 @@ export const mikroOrmSerializer = async <TOutput extends object>(
): Promise<TOutput> => {
options ??= {}
const data_ = Array.isArray(data) ? data : [data]
const data_ = (Array.isArray(data) ? data : [data]).filter(Boolean)
const forSerialization: unknown[] = []
const notForSerialization: unknown[] = []

View File

@@ -1,4 +1,4 @@
import { OrchestrationUtils, promiseAll } from "@medusajs/utils"
import { OrchestrationUtils, deepCopy, promiseAll } from "@medusajs/utils"
async function resolveProperty(property, transactionContext) {
const { invoke: invokeRes } = transactionContext
@@ -31,6 +31,8 @@ async function resolveProperty(property, transactionContext) {
* @internal
*/
export async function resolveValue(input, transactionContext) {
const copiedInput = deepCopy(input)
const unwrapInput = async (
inputTOUnwrap: Record<string, unknown>,
parentRef: any
@@ -63,9 +65,9 @@ export async function resolveValue(input, transactionContext) {
return parentRef
}
const result = input?.__type
? await resolveProperty(input, transactionContext)
: await unwrapInput(input, {})
const result = copiedInput?.__type
? await resolveProperty(copiedInput, transactionContext)
: await unwrapInput(copiedInput, {})
return result && JSON.parse(JSON.stringify(result))
}