diff --git a/packages/core/core-flows/src/cart/workflows/complete-cart.ts b/packages/core/core-flows/src/cart/workflows/complete-cart.ts index 0a96289e00..909e71bd4b 100644 --- a/packages/core/core-flows/src/cart/workflows/complete-cart.ts +++ b/packages/core/core-flows/src/cart/workflows/complete-cart.ts @@ -57,13 +57,13 @@ export const THREE_DAYS = 60 * 60 * 24 * 3 export const completeCartWorkflowId = "complete-cart" /** - * This workflow completes a cart and places an order for the customer. It's executed by the + * This workflow completes a cart and places an order for the customer. It's executed by the * [Complete Cart Store API Route](https://docs.medusajs.com/api/store#carts_postcartsidcomplete). - * + * * You can use this workflow within your own customizations or custom workflows, allowing you to wrap custom logic around completing a cart. - * For example, in the [Subscriptions recipe](https://docs.medusajs.com/resources/recipes/subscriptions/examples/standard#create-workflow), + * For example, in the [Subscriptions recipe](https://docs.medusajs.com/resources/recipes/subscriptions/examples/standard#create-workflow), * this workflow is used within another workflow that creates a subscription order. - * + * * @example * const { result } = await completeCartWorkflow(container) * .run({ @@ -71,11 +71,11 @@ export const completeCartWorkflowId = "complete-cart" * id: "cart_123" * } * }) - * + * * @summary - * + * * Complete a cart and place an order. - * + * * @property hooks.validate - This hook is executed before all operations. You can consume this hook to perform any custom validation. If validation fails, you can throw an error to stop the workflow execution. */ export const completeCartWorkflow = createWorkflow( @@ -118,7 +118,6 @@ export const completeCartWorkflow = createWorkflow( // We choose the first payment session, as there will only be one active payment session // This might change in the future. id: paymentSessions[0].id, - context: { cart_id: cart.id }, }) const { variants, sales_channel_id } = transform({ cart }, (data) => { diff --git a/packages/core/core-flows/src/order/workflows/mark-payment-collection-as-paid.ts b/packages/core/core-flows/src/order/workflows/mark-payment-collection-as-paid.ts index ef057abace..9e8cf498b4 100644 --- a/packages/core/core-flows/src/order/workflows/mark-payment-collection-as-paid.ts +++ b/packages/core/core-flows/src/order/workflows/mark-payment-collection-as-paid.ts @@ -26,14 +26,14 @@ export type ThrowUnlessPaymentCollectionNotePaidInput = { /** * This step validates that the payment collection is not paid. If not valid, * the step will throw an error. - * + * * :::note - * + * * You can retrieve a payment collection's details using [Query](https://docs.medusajs.com/learn/fundamentals/module-links/query), * or [useQueryGraphStep](https://docs.medusajs.com/resources/references/medusa-workflows/steps/useQueryGraphStep). - * + * * ::: - * + * * @example * const data = throwUnlessPaymentCollectionNotPaid({ * paymentCollection: { @@ -77,10 +77,10 @@ export const markPaymentCollectionAsPaidId = "mark-payment-collection-as-paid" /** * This workflow marks a payment collection for an order as paid. It's used by the * [Mark Payment Collection as Paid Admin API Route](https://docs.medusajs.com/api/admin#payment-collections_postpaymentcollectionsidmarkaspaid). - * + * * You can use this workflow within your customizations or your own custom workflows, allowing you to wrap custom logic around * marking a payment collection for an order as paid. - * + * * @example * const { result } = await markPaymentCollectionAsPaid(container) * .run({ @@ -89,16 +89,14 @@ export const markPaymentCollectionAsPaidId = "mark-payment-collection-as-paid" * payment_collection_id: "paycol_123", * } * }) - * + * * @summary - * + * * Mark a payment collection for an order as paid. */ export const markPaymentCollectionAsPaid = createWorkflow( markPaymentCollectionAsPaidId, - ( - input: WorkflowData - ) => { + (input: WorkflowData) => { const paymentCollection = useRemoteQueryStep({ entry_point: "payment_collection", fields: ["id", "status", "amount"], @@ -120,7 +118,6 @@ export const markPaymentCollectionAsPaid = createWorkflow( const payment = authorizePaymentSessionStep({ id: paymentSession.id, - context: { order_id: input.order_id }, }) capturePaymentWorkflow.runAsStep({ diff --git a/packages/core/core-flows/src/payment-collection/steps/create-payment-account-holder.ts b/packages/core/core-flows/src/payment-collection/steps/create-payment-account-holder.ts new file mode 100644 index 0000000000..cb820a357b --- /dev/null +++ b/packages/core/core-flows/src/payment-collection/steps/create-payment-account-holder.ts @@ -0,0 +1,29 @@ +import { + IPaymentModuleService, + CreateAccountHolderDTO, +} from "@medusajs/framework/types" +import { Modules } from "@medusajs/framework/utils" +import { StepResponse, createStep } from "@medusajs/framework/workflows-sdk" + +export const createPaymentAccountHolderStepId = "create-payment-account-holder" +/** + * This step creates the account holder in the payment provider. + */ +export const createPaymentAccountHolderStep = createStep( + createPaymentAccountHolderStepId, + async (data: CreateAccountHolderDTO, { container }) => { + const service = container.resolve(Modules.PAYMENT) + + const accountHolder = await service.createAccountHolder(data) + + return new StepResponse(accountHolder, accountHolder) + }, + async (createdAccountHolder, { container }) => { + if (!createdAccountHolder) { + return + } + + const service = container.resolve(Modules.PAYMENT) + await service.deleteAccountHolder(createdAccountHolder.id) + } +) diff --git a/packages/core/core-flows/src/payment-collection/steps/create-payment-session.ts b/packages/core/core-flows/src/payment-collection/steps/create-payment-session.ts index 6f5c9d6942..5e7efe58ee 100644 --- a/packages/core/core-flows/src/payment-collection/steps/create-payment-session.ts +++ b/packages/core/core-flows/src/payment-collection/steps/create-payment-session.ts @@ -24,7 +24,7 @@ export interface CreatePaymentSessionStepInput { amount: BigNumberInput /** * The currency code of the payment session. - * + * * @example * usd */ @@ -42,7 +42,7 @@ export interface CreatePaymentSessionStepInput { export const createPaymentSessionStepId = "create-payment-session" /** - * This step creates a payment session. + * This step creates a payment session. */ export const createPaymentSessionStep = createStep( createPaymentSessionStepId, diff --git a/packages/core/core-flows/src/payment-collection/steps/index.ts b/packages/core/core-flows/src/payment-collection/steps/index.ts index 8e1be26418..ed94bd173f 100644 --- a/packages/core/core-flows/src/payment-collection/steps/index.ts +++ b/packages/core/core-flows/src/payment-collection/steps/index.ts @@ -5,3 +5,4 @@ export * from "./delete-refund-reasons" export * from "./update-payment-collection" export * from "./update-refund-reasons" export * from "./validate-deleted-payment-sessions" +export * from "./create-payment-account-holder" diff --git a/packages/core/core-flows/src/payment-collection/workflows/create-payment-session.ts b/packages/core/core-flows/src/payment-collection/workflows/create-payment-session.ts index 53b227caa2..77266f5813 100644 --- a/packages/core/core-flows/src/payment-collection/workflows/create-payment-session.ts +++ b/packages/core/core-flows/src/payment-collection/workflows/create-payment-session.ts @@ -1,5 +1,6 @@ import { - PaymentProviderContext, + AccountHolderDTO, + CustomerDTO, PaymentSessionDTO, } from "@medusajs/framework/types" import { @@ -8,10 +9,15 @@ import { createWorkflow, parallelize, transform, + when, } from "@medusajs/framework/workflows-sdk" -import { useRemoteQueryStep } from "../../common" -import { createPaymentSessionStep } from "../steps" +import { createRemoteLinkStep, useRemoteQueryStep } from "../../common" +import { + createPaymentSessionStep, + createPaymentAccountHolderStep, +} from "../steps" import { deletePaymentSessionsWorkflow } from "./delete-payment-sessions" +import { isPresent, Modules } from "@medusajs/framework/utils" /** * The data to create payment sessions. @@ -26,25 +32,31 @@ export interface CreatePaymentSessionsWorkflowInput { * This provider is used to later process the payment sessions and their payments. */ provider_id: string + /** + * The ID of the customer that the payment session should be associated with. + */ + customer_id?: string /** * Custom data relevant for the payment provider to process the payment session. * Learn more in [this documentation](https://docs.medusajs.com/resources/commerce-modules/payment/payment-session#data-property). */ data?: Record + /** * Additional context that's useful for the payment provider to process the payment session. + * Currently all of the context is calculated within the workflow. */ - context?: PaymentProviderContext + context?: Record } export const createPaymentSessionsWorkflowId = "create-payment-sessions" /** * This workflow creates payment sessions. It's used by the * [Initialize Payment Session Store API Route](https://docs.medusajs.com/api/store#payment-collections_postpaymentcollectionsidpaymentsessions). - * + * * You can use this workflow within your own customizations or custom workflows, allowing you * to create payment sessions in your custom flows. - * + * * @example * const { result } = await createPaymentSessionsWorkflow(container) * .run({ @@ -53,9 +65,9 @@ export const createPaymentSessionsWorkflowId = "create-payment-sessions" * provider_id: "pp_system" * } * }) - * + * * @summary - * + * * Create payment sessions. */ export const createPaymentSessionsWorkflow = createWorkflow( @@ -68,16 +80,89 @@ export const createPaymentSessionsWorkflow = createWorkflow( fields: ["id", "amount", "currency_code", "payment_sessions.*"], variables: { id: input.payment_collection_id }, list: false, + }).config({ name: "get-payment-collection" }) + + const { paymentCustomer, accountHolder } = when( + "customer-id-exists", + { input }, + (data) => { + return !!data.input.customer_id + } + ).then(() => { + const customer: CustomerDTO & { account_holder: AccountHolderDTO } = + useRemoteQueryStep({ + entry_point: "customer", + fields: [ + "id", + "email", + "company_name", + "first_name", + "last_name", + "phone", + "addresses.*", + "account_holder.*", + "metadata", + ], + variables: { id: input.customer_id }, + list: false, + }).config({ name: "get-customer" }) + + const paymentCustomer = transform({ customer }, (data) => { + return { + ...data.customer, + billing_address: + data.customer.addresses?.find((a) => a.is_default_billing) ?? + data.customer.addresses?.[0], + } + }) + + const accountHolderInput = { + provider_id: input.provider_id, + context: { + // The module is idempotent, so if there already is a linked account holder, the module will simply return it back. + account_holder: customer.account_holder, + customer: paymentCustomer, + }, + } + + const accountHolder = createPaymentAccountHolderStep(accountHolderInput) + return { paymentCustomer, accountHolder } + }) + + when( + "account-holder-created", + { paymentCustomer, accountHolder }, + (data) => { + return ( + !isPresent(data.paymentCustomer?.account_holder) && + isPresent(data.accountHolder) + ) + } + ).then(() => { + createRemoteLinkStep([ + { + [Modules.CUSTOMER]: { + customer_id: paymentCustomer.id, + }, + [Modules.PAYMENT]: { + account_holder_id: accountHolder.id, + }, + }, + ]) }) const paymentSessionInput = transform( - { paymentCollection, input }, + { paymentCollection, paymentCustomer, accountHolder, input }, (data) => { return { payment_collection_id: data.input.payment_collection_id, provider_id: data.input.provider_id, data: data.input.data, - context: data.input.context, + context: { + ...data.input.context, + customer: data.paymentCustomer, + account_holder: data.accountHolder, + }, amount: data.paymentCollection.amount, currency_code: data.paymentCollection.currency_code, } diff --git a/packages/core/core-flows/src/payment/steps/authorize-payment-session.ts b/packages/core/core-flows/src/payment/steps/authorize-payment-session.ts index 3d94040dd3..826556b473 100644 --- a/packages/core/core-flows/src/payment/steps/authorize-payment-session.ts +++ b/packages/core/core-flows/src/payment/steps/authorize-payment-session.ts @@ -23,13 +23,13 @@ export type AuthorizePaymentSessionStepInput = { * The context to authorize the payment session with. * This context is passed to the payment provider associated with the payment session. */ - context: Record + context?: Record } export const authorizePaymentSessionStepId = "authorize-payment-session-step" /** * This step authorizes a payment session. - * + * * @example * const data = authorizePaymentSessionStep({ * id: "payses_123", diff --git a/packages/core/core-flows/src/payment/workflows/capture-payment.ts b/packages/core/core-flows/src/payment/workflows/capture-payment.ts index b188f285d4..99c4307634 100644 --- a/packages/core/core-flows/src/payment/workflows/capture-payment.ts +++ b/packages/core/core-flows/src/payment/workflows/capture-payment.ts @@ -31,12 +31,12 @@ export type CapturePaymentWorkflowInput = { export const capturePaymentWorkflowId = "capture-payment-workflow" /** - * This workflow captures a payment. It's used by the + * This workflow captures a payment. It's used by the * [Capture Payment Admin API Route](https://docs.medusajs.com/api/admin#payments_postpaymentsidcapture). - * + * * You can use this workflow within your own customizations or custom workflows, allowing you * to capture a payment in your custom flows. - * + * * @example * const { result } = await capturePaymentWorkflow(container) * .run({ @@ -44,9 +44,9 @@ export const capturePaymentWorkflowId = "capture-payment-workflow" * payment_id: "pay_123" * } * }) - * + * * @summary - * + * * Capture a payment. */ export const capturePaymentWorkflow = createWorkflow( diff --git a/packages/core/types/src/http/payment/store/payloads.ts b/packages/core/types/src/http/payment/store/payloads.ts index 26a58f3c0d..a270b6c340 100644 --- a/packages/core/types/src/http/payment/store/payloads.ts +++ b/packages/core/types/src/http/payment/store/payloads.ts @@ -8,15 +8,10 @@ export interface StoreInitializePaymentSession { * for. */ provider_id: string - /** - * The payment's context, such as the customer or address details. if the customer is logged-in, - * the customer id is set in the context under a `customer.id` property. - */ - context?: Record /** * Any data necessary for the payment provider to process the payment. - * + * * Learn more in [this documentation](https://docs.medusajs.com/resources/commerce-modules/payment/payment-session#data-property). */ data?: Record -} \ No newline at end of file +} diff --git a/packages/core/types/src/payment/common.ts b/packages/core/types/src/payment/common.ts index 9450ad949c..f67cb025cf 100644 --- a/packages/core/types/src/payment/common.ts +++ b/packages/core/types/src/payment/common.ts @@ -662,3 +662,45 @@ export interface PaymentMethodDTO { */ provider_id: string } + +export interface AccountHolderDTO { + /** + * The ID of the account holder. + */ + id: string + + /** + * The ID of the associated payment provider. + */ + provider_id: string + + /** + * The external ID of the account holder in the payment provider system. + */ + external_id: string + + /** + * The email of the account holder. + */ + email: string | null + + /** + * The data of the account holder, as returned by the payment provider. + */ + data: Record + + /** + * When the account holder was created. + */ + created_at?: string | Date | null + + /** + * When the account holder was updated. + */ + updated_at?: string | Date | null + + /** + * Holds custom data in key-value pairs. + */ + metadata?: Record | null +} diff --git a/packages/core/types/src/payment/mutations.ts b/packages/core/types/src/payment/mutations.ts index 72e42ba323..ec6b3591b1 100644 --- a/packages/core/types/src/payment/mutations.ts +++ b/packages/core/types/src/payment/mutations.ts @@ -1,6 +1,6 @@ import { BigNumberInput } from "../totals" import { PaymentCollectionStatus } from "./common" -import { PaymentProviderContext } from "./provider" +import { PaymentCustomerDTO, PaymentProviderContext } from "./provider" /** * The payment collection to be created. @@ -255,6 +255,26 @@ export interface CreatePaymentProviderDTO { is_enabled?: boolean } +/** + * The payment session to be created. + */ +export interface CreateAccountHolderDTO { + /** + * The provider's ID. + */ + provider_id: string + + /** + * Necessary context data for the associated payment provider. + */ + context: PaymentProviderContext & { + /** + * The customer information from Medusa. + */ + customer: PaymentCustomerDTO + } +} + /** * The details of the webhook event payload. */ diff --git a/packages/core/types/src/payment/provider.ts b/packages/core/types/src/payment/provider.ts index 2fcc63ff90..a976f9b47d 100644 --- a/packages/core/types/src/payment/provider.ts +++ b/packages/core/types/src/payment/provider.ts @@ -1,7 +1,6 @@ import { AddressDTO } from "../address" -import { CustomerDTO } from "../customer" import { BigNumberInput, BigNumberValue } from "../totals" -import { PaymentSessionStatus } from "./common" +import { AccountHolderDTO, PaymentSessionStatus } from "./common" import { ProviderWebhookPayload } from "./mutations" /** @@ -12,7 +11,19 @@ export type PaymentAddressDTO = Partial /** * The customer associated with the payment. */ -export type PaymentCustomerDTO = Partial +export type PaymentCustomerDTO = { + id: string + email: string + company_name?: string | null + first_name?: string | null + last_name?: string | null + phone?: string | null + billing_address?: PaymentAddressDTO | null +} + +export type PaymentAccountHolderDTO = { + data: Record +} /** * Normalized events from payment provider to internal payment module events. @@ -26,33 +37,26 @@ export type PaymentActions = /** * @interface * - * Context data provided to the payment provider when authorizing a payment session. + * Context data provided to the payment provider */ export type PaymentProviderContext = { /** - * The payment's billing address. + * The account holder information, if available for the payment provider. */ - billing_address?: PaymentAddressDTO + account_holder?: PaymentAccountHolderDTO /** - * The associated customer's email. - */ - email?: string - - /** - * The ID of payment session the provider payment is associated with. - */ - session_id?: string - - /** - * The customer associated with this payment. + * The customer information from Medusa. */ customer?: PaymentCustomerDTO +} - /** - * The extra fields specific to the provider session. - */ - extra?: Record +export type PaymentProviderInput = { + // Data is a combination of the input from the user and whatever is stored in the DB for this entity. + data?: Record + + // The context for this payment operation. The data is guaranteed to be validated and not directly provided by the user. + context?: PaymentProviderContext } /** @@ -61,12 +65,7 @@ export type PaymentProviderContext = { * The data used initiate a payment in a provider when a payment * session is created. */ -export type CreatePaymentProviderSession = { - /** - * A context necessary for the payment provider. - */ - context: PaymentProviderContext - +export type InitiatePaymentInput = PaymentProviderInput & { /** * The amount to be authorized. */ @@ -78,34 +77,12 @@ export type CreatePaymentProviderSession = { currency_code: string } -export type SavePaymentMethod = { - /** - * Any data that should be used by the provider for saving the payment method. - */ - data: Record - - /** - * The context of the payment provider, such as the customer ID. - */ - context: PaymentProviderContext -} - /** * @interface * * The attributes to update a payment related to a payment session in a provider. */ -export type UpdatePaymentProviderSession = { - /** - * A payment's context. - */ - context: PaymentProviderContext - - /** - * The `data` field of the payment session. - */ - data: Record - +export type UpdatePaymentInput = PaymentProviderInput & { /** * The payment session's amount. */ @@ -117,29 +94,58 @@ export type UpdatePaymentProviderSession = { currency_code: string } +export type DeletePaymentInput = PaymentProviderInput + +export type AuthorizePaymentInput = PaymentProviderInput + +export type CapturePaymentInput = PaymentProviderInput + +export type RefundPaymentInput = PaymentProviderInput & { + /** + * The amount to refund. + */ + amount: BigNumberInput +} + +export type RetrievePaymentInput = PaymentProviderInput + +export type CancelPaymentInput = PaymentProviderInput + +export type CreateAccountHolderInput = PaymentProviderInput & { + context: Omit & { + customer: PaymentCustomerDTO + } +} + +export type DeleteAccountHolderInput = PaymentProviderInput & { + context: Omit & { + account_holder: Partial + } +} + +export type ListPaymentMethodsInput = PaymentProviderInput + +export type SavePaymentMethodInput = PaymentProviderInput + +export type GetPaymentStatusInput = PaymentProviderInput + /** * @interface * * The response of operations on a payment. */ -export type PaymentProviderSessionResponse = { +export type PaymentProviderOutput = { /** - * The data to be stored in the `data` field of the Payment Session to be created. - * The `data` field is useful to hold any data required by the third-party provider to process the payment or retrieve its details at a later point. + * The unstrucvtured data returned from the payment provider. The content will vary between providers. */ - data: Record + data?: Record } -export type SavePaymentMethodResponse = { +export type InitiatePaymentOutput = PaymentProviderOutput & { /** - * The ID of the payment method in the payment provider. + * The ID of the payment session in the payment provider. */ id: string - - /** - * The data returned from the payment provider after saving the payment method. - */ - data: Record } /** @@ -147,59 +153,53 @@ export type SavePaymentMethodResponse = { * * The successful result of authorizing a payment session using a payment provider. */ -export type PaymentProviderAuthorizeResponse = { +export type AuthorizePaymentOutput = PaymentProviderOutput & { /** * The status of the payment, which will be stored in the payment session's `status` field. */ status: PaymentSessionStatus - - /** - * The `data` to be stored in the payment session's `data` field. - */ - data: PaymentProviderSessionResponse["data"] } -export type PaymentMethodResponse = { +export type UpdatePaymentOutput = PaymentProviderOutput + +export type DeletePaymentOutput = PaymentProviderOutput + +export type CapturePaymentOutput = PaymentProviderOutput + +export type RefundPaymentOutput = PaymentProviderOutput + +export type RetrievePaymentOutput = PaymentProviderOutput + +export type CancelPaymentOutput = PaymentProviderOutput + +export type CreateAccountHolderOutput = PaymentProviderOutput & { + /** + * The ID of the account holder in the payment provider. + */ id: string - data: Record } -/** - * @interface - * - * The details of which payment provider to use to perform an action, and what - * data to be passed to that provider. - */ -export type PaymentProviderDataInput = { - /** - * The ID of the provider to be used to perform an action. - */ - provider_id: string +export type DeleteAccountHolderOutput = PaymentProviderOutput +export type ListPaymentMethodsOutput = (PaymentProviderOutput & { /** - * The data to be passed to the provider. + * The ID of the payment method in the payment provider. */ - data: Record + id: string +})[] + +export type SavePaymentMethodOutput = PaymentProviderOutput & { + /** + * The ID of the payment method in the payment provider. + */ + id: string } -/** - * An object that is returned in case of an error. - */ -export interface PaymentProviderError { +export type GetPaymentStatusOutput = PaymentProviderOutput & { /** - * The error message + * The status of the payment, which will be stored in the payment session's `status` field. */ - error: string - - /** - * The error code. - */ - code?: string - - /** - * Any additional helpful details. - */ - detail?: any + status: PaymentSessionStatus } /** @@ -244,51 +244,39 @@ export interface IPaymentProvider { */ getIdentifier(): string - initiatePayment( - data: CreatePaymentProviderSession - ): Promise + initiatePayment(data: InitiatePaymentInput): Promise - updatePayment( - context: UpdatePaymentProviderSession - ): Promise + updatePayment(data: UpdatePaymentInput): Promise - deletePayment( - paymentSessionData: Record - ): Promise + deletePayment(data: DeletePaymentInput): Promise - authorizePayment( - paymentSessionData: Record, - context: Record - ): Promise + authorizePayment(data: AuthorizePaymentInput): Promise - capturePayment( - paymentSessionData: Record - ): Promise + capturePayment(data: CapturePaymentInput): Promise - refundPayment( - paymentSessionData: Record, - refundAmount: BigNumberInput - ): Promise + refundPayment(data: RefundPaymentInput): Promise - retrievePayment( - paymentSessionData: Record - ): Promise + retrievePayment(data: RetrievePaymentInput): Promise - cancelPayment( - paymentSessionData: Record - ): Promise + cancelPayment(data: CancelPaymentInput): Promise + + createAccountHolder?( + data: CreateAccountHolderInput + ): Promise + + deleteAccountHolder?( + data: DeleteAccountHolderInput + ): Promise listPaymentMethods?( - context: PaymentProviderContext - ): Promise + data: ListPaymentMethodsInput + ): Promise savePaymentMethod?( - input: SavePaymentMethod - ): Promise + data: SavePaymentMethodInput + ): Promise - getPaymentStatus( - paymentSessionData: Record - ): Promise + getPaymentStatus(data: GetPaymentStatusInput): Promise getWebhookActionAndData( data: ProviderWebhookPayload["payload"] diff --git a/packages/core/types/src/payment/service.ts b/packages/core/types/src/payment/service.ts index c72501563d..5d42f2fbed 100644 --- a/packages/core/types/src/payment/service.ts +++ b/packages/core/types/src/payment/service.ts @@ -3,6 +3,7 @@ import { RestoreReturn, SoftDeleteReturn } from "../dal" import { IModuleService } from "../modules-sdk" import { Context } from "../shared-context" import { + AccountHolderDTO, CaptureDTO, FilterableCaptureProps, FilterablePaymentCollectionProps, @@ -31,6 +32,7 @@ import { UpdatePaymentDTO, UpdatePaymentSessionDTO, UpdateRefundReasonDTO, + CreateAccountHolderDTO, UpsertPaymentCollectionDTO, } from "./mutations" import { WebhookActionResult } from "./provider" @@ -751,6 +753,63 @@ export interface IPaymentModuleService extends IModuleService { sharedContext?: Context ): Promise<[PaymentProviderDTO[], number]> + /** + * This method creates(if supported by provider) the account holder in the payment provider. + * + * @param {CreateAccountHolderDTO} data - The details of the account holder. + * @param {Context} sharedContext - A context used to share resources, such as transaction manager, between the application and the module. + * @returns {Promise>} The account holder's details in the payment provider, typically just the ID. + * + * @example + * const accountHolder = + * await paymentModuleService.createAccountHolder( + * { + * provider_id: "stripe", + * context: { + * customer: { + * id: "cus_123", + * }, + * }, + * } + * ) + * + * remoteLink.create([{ + * [Modules.CUSTOMER]: { + * customer_id: "cus_123", + * }, + * [Modules.PAYMENT]: { + * account_holder_id: accountHolder.id, + * }, + * }]) + */ + createAccountHolder( + input: CreateAccountHolderDTO, + sharedContext?: Context + ): Promise + + /** + * This method deletes the account holder in the payment provider. + * + * @param {string} id - The account holder's ID. + * @param {Context} sharedContext - A context used to share resources, such as transaction manager, between the application and the module. + * @returns {Promise} Resolves when the account holder is deleted successfully. + * + * @example + * await paymentModuleService.deleteAccountHolder({ + * id: "acc_holder_123", + * }) + * + * remoteLink.dismiss([{ + * [Modules.CUSTOMER]: { + * customer_id: "cus_123", + * }, + * [Modules.PAYMENT]: { + * account_holder_id: "acc_holder_123", + * }, + * }]) + */ + deleteAccountHolder(id: string, sharedContext?: Context): Promise + /** * This method retrieves all payment methods based on the context and configuration. * diff --git a/packages/core/utils/src/link/links.ts b/packages/core/utils/src/link/links.ts index 7db6c00ae5..a5c1ef7b44 100644 --- a/packages/core/utils/src/link/links.ts +++ b/packages/core/utils/src/link/links.ts @@ -122,4 +122,10 @@ export const LINKS = { Modules.FULFILLMENT, "shipping_profile_id" ), + CustomerAccountHolder: composeLinkName( + Modules.CUSTOMER, + "customer_id", + Modules.PAYMENT, + "account_holder_id" + ), } diff --git a/packages/core/utils/src/payment/abstract-payment-provider.ts b/packages/core/utils/src/payment/abstract-payment-provider.ts index abcfc39f23..f49df0b93d 100644 --- a/packages/core/utils/src/payment/abstract-payment-provider.ts +++ b/packages/core/utils/src/payment/abstract-payment-provider.ts @@ -1,12 +1,25 @@ import { - CreatePaymentProviderSession, IPaymentProvider, - PaymentProviderError, - PaymentProviderSessionResponse, - PaymentSessionStatus, ProviderWebhookPayload, - UpdatePaymentProviderSession, WebhookActionResult, + CapturePaymentInput, + CapturePaymentOutput, + AuthorizePaymentInput, + AuthorizePaymentOutput, + CancelPaymentInput, + CancelPaymentOutput, + InitiatePaymentInput, + InitiatePaymentOutput, + DeletePaymentInput, + DeletePaymentOutput, + GetPaymentStatusInput, + GetPaymentStatusOutput, + RefundPaymentInput, + RefundPaymentOutput, + RetrievePaymentInput, + RetrievePaymentOutput, + UpdatePaymentInput, + UpdatePaymentOutput, } from "@medusajs/types" export abstract class AbstractPaymentProvider> @@ -147,48 +160,34 @@ export abstract class AbstractPaymentProvider> * * In this method, use the third-party provider to capture the payment. * - * @param paymentData - The `data` property of the payment. Make sure to store in it - * any helpful identification for your third-party integration. - * @returns The new data to store in the payment's `data` property, or an error object. + * @param input - The input to capture the payment. The `data` field should contain the data from the payment provider. when the payment was created. + * @returns The new data to store in the payment's `data` property. Throws in case of an error. * * @example * // other imports... * import { - * PaymentProviderError, - * PaymentProviderSessionResponse, + * CapturePaymentInput, + * CapturePaymentOutput, * } from "@medusajs/framework/types" * * class MyPaymentProviderService extends AbstractPaymentProvider< * Options * > { * async capturePayment( - * paymentData: Record - * ): Promise { - * const externalId = paymentData.id + * input: CapturePaymentInput + * ): Promise { + * const externalId = input.data?.id * - * try { * // assuming you have a client that captures the payment - * const newData = await this.client.capturePayment(externalId) - * - * return { - * ...newData, - * id: externalId - * } - * } catch (e) { - * return { - * error: e, - * code: "unknown", - * detail: e - * } - * } + * const newData = await this.client.capturePayment(externalId) + * return {data: newData} * } - * * // ... * } */ abstract capturePayment( - paymentData: Record - ): Promise + input: CapturePaymentInput + ): Promise /** * This method authorizes a payment session. When authorized successfully, a payment is created by the Payment @@ -199,18 +198,14 @@ export abstract class AbstractPaymentProvider> * * To automatically capture the payment after authorization, return the status `captured`. * - * @param paymentSessionData - The `data` property of the payment session. Make sure to store in it - * any helpful identification for your third-party integration. - * @param context - The context in which the payment is being authorized. For example, in checkout, - * the context has a `cart_id` property indicating the ID of the associated cart. - * @returns Either an object of the new data to store in the created payment's `data` property and the - * payment's status, or an error object. Make sure to set in `data` anything useful to later retrieve the session. + * @param input - The input to authorize the payment. The `data` field should contain the data from the payment provider. when the payment was created. + * @returns The status of the authorization, along with the `data` field about the payment. Throws in case of an error. * * @example * // other imports... * import { - * PaymentProviderError, - * PaymentProviderSessionResponse, + * AuthorizePaymentInput, + * AuthorizePaymentOutput, * PaymentSessionStatus * } from "@medusajs/framework/types" * @@ -219,33 +214,16 @@ export abstract class AbstractPaymentProvider> * Options * > { * async authorizePayment( - * paymentSessionData: Record, - * context: Record - * ): Promise< - * PaymentProviderError | { - * status: PaymentSessionStatus - * data: PaymentProviderSessionResponse["data"] - * } - * > { - * const externalId = paymentSessionData.id + * input: AuthorizePaymentInput + * ): Promise { + * const externalId = input.data?.id * - * try { - * // assuming you have a client that authorizes the payment - * const paymentData = await this.client.authorizePayment(externalId) + * // assuming you have a client that authorizes the payment + * const paymentData = await this.client.authorizePayment(externalId) * - * return { - * data: { - * ...paymentData, - * id: externalId - * }, - * status: "authorized" - * } - * } catch (e) { - * return { - * error: e, - * code: "unknown", - * detail: e - * } + * return { + * data: paymentData, + * status: "authorized" * } * } * @@ -253,28 +231,14 @@ export abstract class AbstractPaymentProvider> * } */ abstract authorizePayment( - paymentSessionData: Record, - context: Record - ): Promise< - | PaymentProviderError - | { - /** - * The new status of the payment. - */ - status: PaymentSessionStatus - /** - * The data to store in the created payment's `data` property. - */ - data: PaymentProviderSessionResponse["data"] - } - > + input: AuthorizePaymentInput + ): Promise /** * This method cancels a payment. * - * @param paymentData - The `data` property of the payment. Make sure to store in it - * any helpful identification for your third-party integration. - * @returns An error object if an error occurs, or the data received from the integration. + * @param input - The input to cancel the payment. The `data` field should contain the data from the payment provider. when the payment was created. + * @returns The new data to store in the payment's `data` property, if any. Throws in case of an error. * * @example * // other imports... @@ -288,42 +252,34 @@ export abstract class AbstractPaymentProvider> * Options * > { * async cancelPayment( - * paymentData: Record - * ): Promise { - * const externalId = paymentData.id + * input: CancelPaymentInput + * ): Promise { + * const externalId = input.data?.id * - * try { - * // assuming you have a client that cancels the payment - * const paymentData = await this.client.cancelPayment(externalId) - * } catch (e) { - * return { - * error: e, - * code: "unknown", - * detail: e - * } - * } + * // assuming you have a client that cancels the payment + * const paymentData = await this.client.cancelPayment(externalId) + * return { data: paymentData } * } * * // ... * } */ abstract cancelPayment( - paymentData: Record - ): Promise + input: CancelPaymentInput + ): Promise /** * This method is used when a payment session is created. It can be used to initiate the payment * in the third-party session, before authorizing or capturing the payment later. * - * @param context - The details of the payment session and its context. - * @returns An object whose `data` property is set in the created payment session, or an error - * object. Make sure to set in `data` anything useful to later retrieve the session. + * @param input - The input to create the payment session. + * @returns The new data to store in the payment's `data` property. Throws in case of an error. * * @example * // other imports... * import { - * PaymentProviderError, - * PaymentProviderSessionResponse, + * InitiatePaymentInput, + * InitiatePaymentOutput, * } from "@medusajs/framework/types" * * @@ -331,32 +287,22 @@ export abstract class AbstractPaymentProvider> * Options * > { * async initiatePayment( - * context: CreatePaymentProviderSession - * ): Promise { + * input: InitiatePaymentInput + * ): Promise { * const { * amount, * currency_code, * context: customerDetails - * } = context + * } = input * - * try { - * // assuming you have a client that initializes the payment - * const response = await this.client.init( - * amount, currency_code, customerDetails - * ) + * // assuming you have a client that initializes the payment + * const response = await this.client.init( + * amount, currency_code, customerDetails + * ) * - * return { - * ...response, - * data: { - * id: response.id - * } - * } - * } catch (e) { - * return { - * error: e, - * code: "unknown", - * detail: e - * } + * return { + * id: response.id + * data: response, * } * } * @@ -364,23 +310,22 @@ export abstract class AbstractPaymentProvider> * } */ abstract initiatePayment( - context: CreatePaymentProviderSession - ): Promise + input: InitiatePaymentInput + ): Promise /** * This method is used when a payment session is deleted, which can only happen if it isn't authorized, yet. * * Use this to delete or cancel the payment in the third-party service. * - * @param paymentSessionData - The `data` property of the payment session. Make sure to store in it - * any helpful identification for your third-party integration. - * @returns An error object or the response from the third-party service. + * @param input - The input to delete the payment session. The `data` field should contain the data from the payment provider. when the payment was created. + * @returns The new data to store in the payment's `data` property, if any. Throws in case of an error. * * @example * // other imports... * import { - * PaymentProviderError, - * PaymentProviderSessionResponse, + * DeletePaymentInput, + * DeletePaymentOutput, * } from "@medusajs/framework/types" * * @@ -388,41 +333,34 @@ export abstract class AbstractPaymentProvider> * Options * > { * async deletePayment( - * paymentSessionData: Record - * ): Promise< - * PaymentProviderError | PaymentProviderSessionResponse["data"] - * > { - * const externalId = paymentSessionData.id + * input: DeletePaymentInput + * ): Promise { + * const externalId = input.data?.id * - * try { - * // assuming you have a client that cancels the payment - * await this.client.cancelPayment(externalId) - * } catch (e) { - * return { - * error: e, - * code: "unknown", - * detail: e - * } - * } + * // assuming you have a client that cancels the payment + * await this.client.cancelPayment(externalId) + * return {} + * } * } * * // ... * } */ abstract deletePayment( - paymentSessionData: Record - ): Promise + input: DeletePaymentInput + ): Promise /** * This method gets the status of a payment session based on the status in the third-party integration. * - * @param paymentSessionData - The `data` property of the payment session. Make sure to store in it - * any helpful identification for your third-party integration. - * @returns The payment session's status. + * @param input - The input to get the payment status. The `data` field should contain the data from the payment provider. when the payment was created. + * @returns The payment session's status. It can also return additional `data` from the payment provider. * * @example * // other imports... * import { + * GetPaymentStatusInput, + * GetPaymentStatusOutput, * PaymentSessionStatus * } from "@medusajs/framework/types" * @@ -431,49 +369,43 @@ export abstract class AbstractPaymentProvider> * Options * > { * async getPaymentStatus( - * paymentSessionData: Record - * ): Promise { - * const externalId = paymentSessionData.id + * input: GetPaymentStatusInput + * ): Promise { + * const externalId = input.data?.id * - * try { - * // assuming you have a client that retrieves the payment status - * const status = await this.client.getStatus(externalId) + * // assuming you have a client that retrieves the payment status + * const status = await this.client.getStatus(externalId) * - * switch (status) { - * case "requires_capture": - * return "authorized" + * switch (status) { + * case "requires_capture": + * return {status: "authorized"} * case "success": - * return "captured" + * return {status: "captured"} * case "canceled": - * return "canceled" + * return {status: "canceled"} * default: - * return "pending" - * } - * } catch (e) { - * return "error" - * } + * return {status: "pending"} + * } * } * * // ... * } */ abstract getPaymentStatus( - paymentSessionData: Record - ): Promise + input: GetPaymentStatusInput + ): Promise /** * This method refunds an amount of a payment previously captured. * - * @param paymentData - The `data` property of the payment. Make sure to store in it - * any helpful identification for your third-party integration. - * @param refundAmount The amount to refund. + * @param input - The input to refund the payment. The `data` field should contain the data from the payment provider. when the payment was created. * @returns The new data to store in the payment's `data` property, or an error object. * * @example * // other imports... * import { - * PaymentProviderError, - * PaymentProviderSessionResponse, + * RefundPaymentInput, + * RefundPaymentOutput, * } from "@medusajs/framework/types" * * @@ -481,53 +413,36 @@ export abstract class AbstractPaymentProvider> * Options * > { * async refundPayment( - * paymentData: Record, - * refundAmount: number - * ): Promise< - * PaymentProviderError | PaymentProviderSessionResponse["data"] - * > { - * const externalId = paymentData.id + * input: RefundPaymentInput + * ): Promise { + * const externalId = input.data?.id * - * try { - * // assuming you have a client that refunds the payment - * const newData = await this.client.refund( + * // assuming you have a client that refunds the payment + * const newData = await this.client.refund( * externalId, - * refundAmount + * input.amount * ) * - * return { - * ...newData, - * id: externalId - * } - * } catch (e) { - * return { - * error: e, - * code: "unknown", - * detail: e - * } - * } + * return {data: newData} * } - * * // ... * } */ abstract refundPayment( - paymentData: Record, - refundAmount: number - ): Promise + input: RefundPaymentInput + ): Promise /** * Retrieves the payment's data from the third-party service. * - * @param paymentSessionData - The `data` property of the payment. Make sure to store in it - * any helpful identification for your third-party integration. - * @returns An object to be stored in the payment's `data` property, or an error object. + * @param input - The input to retrieve the payment. The `data` field should contain the data from the payment provider when the payment was created. + * @returns The payment's data as found in the the payment provider. * * @example * // other imports... * import { - * PaymentProviderError, - * PaymentProviderSessionResponse, + * RetrievePaymentInput, + * RetrievePaymentOutput, * } from "@medusajs/framework/types" * * @@ -535,44 +450,31 @@ export abstract class AbstractPaymentProvider> * Options * > { * async retrievePayment( - * paymentSessionData: Record - * ): Promise< - * PaymentProviderError | PaymentProviderSessionResponse["data"] - * > { - * const externalId = paymentSessionData.id + * input: RetrievePaymentInput + * ): Promise { + * const externalId = input.data?.id * - * try { - * // assuming you have a client that retrieves the payment - * return await this.client.retrieve(externalId) - * } catch (e) { - * return { - * error: e, - * code: "unknown", - * detail: e - * } - * } + * // assuming you have a client that retrieves the payment + * return await this.client.retrieve(externalId) * } - * * // ... * } */ abstract retrievePayment( - paymentSessionData: Record - ): Promise + input: RetrievePaymentInput + ): Promise /** * Update a payment in the third-party service that was previously initiated with the {@link initiatePayment} method. * - * @param context - The details of the payment session and its context. - * @returns An object whose `data` property is set in the updated payment session, or an error - * object. Make sure to set in `data` anything useful to later retrieve the session. + * @param input - The input to update the payment. The `data` field should contain the data from the payment provider. when the payment was created. + * @returns The new data to store in the payment's `data` property. Throws in case of an error. * * @example * // other imports... * import { - * UpdatePaymentProviderSession, - * PaymentProviderError, - * PaymentProviderSessionResponse, + * UpdatePaymentInput, + * UpdatePaymentOutput, * } from "@medusajs/framework/types" * * @@ -580,48 +482,30 @@ export abstract class AbstractPaymentProvider> * Options * > { * async updatePayment( - * context: UpdatePaymentProviderSession - * ): Promise { - * const { - * amount, - * currency_code, - * context: customerDetails, - * data - * } = context - * const externalId = data.id + * input: UpdatePaymentInput + * ): Promise { + * const { amount, currency_code, context } = input + * const externalId = input.data?.id * - * try { - * // assuming you have a client that updates the payment - * const response = await this.client.update( - * externalId, + * // assuming you have a client that updates the payment + * const response = await this.client.update( + * externalId, * { * amount, * currency_code, - * customerDetails + * context.customer * } * ) * - * return { - * ...response, - * data: { - * id: response.id - * } - * } - * } catch (e) { - * return { - * error: e, - * code: "unknown", - * detail: e - * } - * } + * return response * } * * // ... * } */ abstract updatePayment( - context: UpdatePaymentProviderSession - ): Promise + input: UpdatePaymentInput + ): Promise /** * This method is executed when a webhook event is received from the third-party payment provider. Use it diff --git a/packages/medusa/src/api/store/payment-collections/[id]/payment-sessions/route.ts b/packages/medusa/src/api/store/payment-collections/[id]/payment-sessions/route.ts index 02eadea4a7..fb430b60a6 100644 --- a/packages/medusa/src/api/store/payment-collections/[id]/payment-sessions/route.ts +++ b/packages/medusa/src/api/store/payment-collections/[id]/payment-sessions/route.ts @@ -11,19 +11,13 @@ export const POST = async ( res: MedusaResponse ) => { const collectionId = req.params.id - const { context = {}, data, provider_id } = req.body + const { provider_id, data } = req.body - // If the customer is logged in, we auto-assign them to the payment collection - if (req.auth_context?.actor_id) { - ;(context as any).customer = { - id: req.auth_context?.actor_id, - } - } const workflowInput = { payment_collection_id: collectionId, provider_id: provider_id, + customer_id: req.auth_context?.actor_id, data, - context, } await createPaymentSessionsWorkflow(req.scope).run({ diff --git a/packages/medusa/src/api/store/payment-collections/validators.ts b/packages/medusa/src/api/store/payment-collections/validators.ts index edc4dc993b..506ca844bb 100644 --- a/packages/medusa/src/api/store/payment-collections/validators.ts +++ b/packages/medusa/src/api/store/payment-collections/validators.ts @@ -12,7 +12,6 @@ export type StoreCreatePaymentSessionType = z.infer< export const StoreCreatePaymentSession = z .object({ provider_id: z.string(), - context: z.record(z.unknown()).optional(), data: z.record(z.unknown()).optional(), }) .strict() diff --git a/packages/modules/link-modules/src/definitions/customer-account-holder.ts b/packages/modules/link-modules/src/definitions/customer-account-holder.ts new file mode 100644 index 0000000000..6b1ee7a1d1 --- /dev/null +++ b/packages/modules/link-modules/src/definitions/customer-account-holder.ts @@ -0,0 +1,68 @@ +import { ModuleJoinerConfig } from "@medusajs/framework/types" +import { LINKS, Modules } from "@medusajs/framework/utils" + +export const CustomerAccountHolder: ModuleJoinerConfig = { + serviceName: LINKS.CustomerAccountHolder, + isLink: true, + databaseConfig: { + tableName: "customer_account_holder", + idPrefix: "custacchldr", + }, + alias: [ + { + name: ["customer_account_holder", "customer_account_holders"], + entity: "LinkCustomerAccountHolder", + }, + ], + primaryKeys: ["id", "customer_id", "account_holder_id"], + relationships: [ + { + serviceName: Modules.CUSTOMER, + entity: "Customer", + primaryKey: "id", + foreignKey: "customer_id", + alias: "customer", + args: { + methodSuffix: "Customers", + }, + }, + { + serviceName: Modules.PAYMENT, + entity: "AccountHolder", + primaryKey: "id", + foreignKey: "account_holder_id", + alias: "account_holder", + args: { + methodSuffix: "AccountHolders", + }, + }, + ], + extends: [ + { + serviceName: Modules.CUSTOMER, + entity: "Customer", + fieldAlias: { + account_holder: "account_holder_link.account_holder", + }, + relationship: { + serviceName: LINKS.CustomerAccountHolder, + primaryKey: "customer_id", + foreignKey: "id", + alias: "account_holder_link", + }, + }, + { + serviceName: Modules.PAYMENT, + entity: "AccountHolder", + fieldAlias: { + customer: "customer_link.customer", + }, + relationship: { + serviceName: LINKS.CustomerAccountHolder, + primaryKey: "account_holder_id", + foreignKey: "id", + alias: "customer_link", + }, + }, + ], +} diff --git a/packages/modules/link-modules/src/definitions/index.ts b/packages/modules/link-modules/src/definitions/index.ts index 98152c83c1..6323e17250 100644 --- a/packages/modules/link-modules/src/definitions/index.ts +++ b/packages/modules/link-modules/src/definitions/index.ts @@ -16,3 +16,4 @@ export * from "./region-payment-provider" export * from "./sales-channel-location" export * from "./shipping-option-price-set" export * from "./product-shipping-profile" +export * from "./customer-account-holder" diff --git a/packages/modules/payment/integration-tests/__tests__/services/payment-module/index.spec.ts b/packages/modules/payment/integration-tests/__tests__/services/payment-module/index.spec.ts index a8cdc25b88..0dbcf0af14 100644 --- a/packages/modules/payment/integration-tests/__tests__/services/payment-module/index.spec.ts +++ b/packages/modules/payment/integration-tests/__tests__/services/payment-module/index.spec.ts @@ -23,12 +23,13 @@ moduleIntegrationTestRunner({ service: PaymentModuleService, }).linkable - expect(Object.keys(linkable)).toHaveLength(5) + expect(Object.keys(linkable)).toHaveLength(6) expect(Object.keys(linkable)).toEqual([ "paymentCollection", "paymentSession", "payment", "refundReason", + "accountHolder", "paymentProvider", ]) @@ -73,6 +74,15 @@ moduleIntegrationTestRunner({ field: "refundReason", }, }, + accountHolder: { + id: { + linkable: "account_holder_id", + entity: "AccountHolder", + primaryKey: "id", + serviceName: "payment", + field: "accountHolder", + }, + }, paymentProvider: { id: { linkable: "payment_provider_id", @@ -100,10 +110,7 @@ moduleIntegrationTestRunner({ currency_code: "usd", data: {}, context: { - extra: {}, - customer: {}, - billing_address: {}, - email: "test@test.test.com", + customer: { id: "cus-id-1", email: "new@test.tsst" }, }, } ) @@ -347,10 +354,10 @@ moduleIntegrationTestRunner({ currency_code: "usd", data: {}, context: { - extra: {}, - customer: {}, - billing_address: {}, - email: "test@test.test.com", + customer: { + id: "cus-id-1", + email: "test@test.test.com", + }, }, }) @@ -402,10 +409,7 @@ moduleIntegrationTestRunner({ currency_code: "usd", data: {}, context: { - extra: {}, - customer: {}, - billing_address: {}, - email: "test@test.test.com", + customer: { id: "cus-id-1", email: "test@test.test.com" }, }, }) .catch((e) => e) @@ -439,10 +443,7 @@ moduleIntegrationTestRunner({ currency_code: "usd", data: {}, context: { - extra: {}, - customer: {}, - billing_address: {}, - email: "test@test.test.com", + customer: { id: "cus-id-1", email: "test@test.test.com" }, }, }) .catch((e) => e) @@ -461,10 +462,7 @@ moduleIntegrationTestRunner({ currency_code: "usd", data: {}, context: { - extra: {}, - customer: {}, - billing_address: {}, - email: "test@test.test.com", + customer: { id: "cus-id-1", email: "new@test.tsst" }, }, }) @@ -474,10 +472,7 @@ moduleIntegrationTestRunner({ currency_code: "eur", data: {}, context: { - extra: {}, - customer: {}, - billing_address: {}, - email: "new@test.tsst", + customer: { id: "cus-id-1", email: "new@test.tsst" }, }, }) @@ -505,10 +500,7 @@ moduleIntegrationTestRunner({ currency_code: "usd", data: {}, context: { - extra: {}, - email: "test@test.com", - billing_address: {}, - customer: {}, + customer: { id: "cus-id-1", email: "new@test.tsst" }, }, }) diff --git a/packages/modules/payment/src/joiner-config.ts b/packages/modules/payment/src/joiner-config.ts index f20a5dad61..09a4b73f75 100644 --- a/packages/modules/payment/src/joiner-config.ts +++ b/packages/modules/payment/src/joiner-config.ts @@ -5,6 +5,7 @@ import { PaymentProvider, PaymentSession, RefundReason, + AccountHolder, } from "@models" import { default as schema } from "./schema" @@ -16,12 +17,14 @@ export const joinerConfig = defineJoinerConfig(Modules.PAYMENT, { PaymentProvider, PaymentSession, RefundReason, + AccountHolder, ], linkableKeys: { payment_id: Payment.name, payment_collection_id: PaymentCollection.name, payment_provider_id: PaymentProvider.name, refund_reason_id: RefundReason.name, + account_holder_id: AccountHolder.name, }, alias: [ { diff --git a/packages/modules/payment/src/migrations/.snapshot-medusa-payment.json b/packages/modules/payment/src/migrations/.snapshot-medusa-payment.json index aeb585024a..5a3946bd97 100644 --- a/packages/modules/payment/src/migrations/.snapshot-medusa-payment.json +++ b/packages/modules/payment/src/migrations/.snapshot-medusa-payment.json @@ -4,6 +4,132 @@ ], "name": "public", "tables": [ + { + "columns": { + "id": { + "name": "id", + "type": "text", + "unsigned": false, + "autoincrement": false, + "primary": false, + "nullable": false, + "mappedType": "text" + }, + "provider_id": { + "name": "provider_id", + "type": "text", + "unsigned": false, + "autoincrement": false, + "primary": false, + "nullable": false, + "mappedType": "text" + }, + "external_id": { + "name": "external_id", + "type": "text", + "unsigned": false, + "autoincrement": false, + "primary": false, + "nullable": false, + "mappedType": "text" + }, + "email": { + "name": "email", + "type": "text", + "unsigned": false, + "autoincrement": false, + "primary": false, + "nullable": true, + "mappedType": "text" + }, + "data": { + "name": "data", + "type": "jsonb", + "unsigned": false, + "autoincrement": false, + "primary": false, + "nullable": false, + "default": "'{}'", + "mappedType": "json" + }, + "metadata": { + "name": "metadata", + "type": "jsonb", + "unsigned": false, + "autoincrement": false, + "primary": false, + "nullable": true, + "mappedType": "json" + }, + "created_at": { + "name": "created_at", + "type": "timestamptz", + "unsigned": false, + "autoincrement": false, + "primary": false, + "nullable": false, + "length": 6, + "default": "now()", + "mappedType": "datetime" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamptz", + "unsigned": false, + "autoincrement": false, + "primary": false, + "nullable": false, + "length": 6, + "default": "now()", + "mappedType": "datetime" + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamptz", + "unsigned": false, + "autoincrement": false, + "primary": false, + "nullable": true, + "length": 6, + "mappedType": "datetime" + } + }, + "name": "account_holder", + "schema": "public", + "indexes": [ + { + "keyName": "IDX_account_holder_deleted_at", + "columnNames": [], + "composite": false, + "constraint": false, + "primary": false, + "unique": false, + "expression": "CREATE INDEX IF NOT EXISTS \"IDX_account_holder_deleted_at\" ON \"account_holder\" (deleted_at) WHERE deleted_at IS NULL" + }, + { + "keyName": "IDX_account_holder_provider_id_external_id_unique", + "columnNames": [], + "composite": false, + "constraint": false, + "primary": false, + "unique": false, + "expression": "CREATE UNIQUE INDEX IF NOT EXISTS \"IDX_account_holder_provider_id_external_id_unique\" ON \"account_holder\" (provider_id, external_id) WHERE deleted_at IS NULL" + }, + { + "keyName": "account_holder_pkey", + "columnNames": [ + "id" + ], + "composite": false, + "constraint": true, + "primary": true, + "unique": true + } + ], + "checks": [], + "foreignKeys": {}, + "nativeEnums": {} + }, { "columns": { "id": { @@ -172,6 +298,7 @@ "keyName": "IDX_payment_collection_deleted_at", "columnNames": [], "composite": false, + "constraint": false, "primary": false, "unique": false, "expression": "CREATE INDEX IF NOT EXISTS \"IDX_payment_collection_deleted_at\" ON \"payment_collection\" (deleted_at) WHERE deleted_at IS NULL" @@ -182,12 +309,14 @@ "id" ], "composite": false, + "constraint": true, "primary": true, "unique": true } ], "checks": [], - "foreignKeys": {} + "foreignKeys": {}, + "nativeEnums": {} }, { "columns": { @@ -294,6 +423,7 @@ "keyName": "IDX_payment_method_token_deleted_at", "columnNames": [], "composite": false, + "constraint": false, "primary": false, "unique": false, "expression": "CREATE INDEX IF NOT EXISTS \"IDX_payment_method_token_deleted_at\" ON \"payment_method_token\" (deleted_at) WHERE deleted_at IS NULL" @@ -304,12 +434,14 @@ "id" ], "composite": false, + "constraint": true, "primary": true, "unique": true } ], "checks": [], - "foreignKeys": {} + "foreignKeys": {}, + "nativeEnums": {} }, { "columns": { @@ -372,6 +504,7 @@ "keyName": "IDX_payment_provider_deleted_at", "columnNames": [], "composite": false, + "constraint": false, "primary": false, "unique": false, "expression": "CREATE INDEX IF NOT EXISTS \"IDX_payment_provider_deleted_at\" ON \"payment_provider\" (deleted_at) WHERE deleted_at IS NULL" @@ -382,12 +515,14 @@ "id" ], "composite": false, + "constraint": true, "primary": true, "unique": true } ], "checks": [], - "foreignKeys": {} + "foreignKeys": {}, + "nativeEnums": {} }, { "columns": { @@ -420,14 +555,15 @@ "payment_provider_id" ], "composite": true, + "constraint": true, "primary": true, "unique": true } ], "checks": [], "foreignKeys": { - "payment_collection_payment_providers_payment_coll_aa276_foreign": { - "constraintName": "payment_collection_payment_providers_payment_coll_aa276_foreign", + "payment_collection_payment_providers_payment_col_aa276_foreign": { + "constraintName": "payment_collection_payment_providers_payment_col_aa276_foreign", "columnNames": [ "payment_collection_id" ], @@ -439,8 +575,8 @@ "deleteRule": "cascade", "updateRule": "cascade" }, - "payment_collection_payment_providers_payment_provider_id_foreign": { - "constraintName": "payment_collection_payment_providers_payment_provider_id_foreign", + "payment_collection_payment_providers_payment_pro_2d555_foreign": { + "constraintName": "payment_collection_payment_providers_payment_pro_2d555_foreign", "columnNames": [ "payment_provider_id" ], @@ -452,7 +588,8 @@ "deleteRule": "cascade", "updateRule": "cascade" } - } + }, + "nativeEnums": {} }, { "columns": { @@ -606,6 +743,7 @@ "keyName": "IDX_payment_session_payment_collection_id", "columnNames": [], "composite": false, + "constraint": false, "primary": false, "unique": false, "expression": "CREATE INDEX IF NOT EXISTS \"IDX_payment_session_payment_collection_id\" ON \"payment_session\" (payment_collection_id) WHERE deleted_at IS NULL" @@ -614,6 +752,7 @@ "keyName": "IDX_payment_session_deleted_at", "columnNames": [], "composite": false, + "constraint": false, "primary": false, "unique": false, "expression": "CREATE INDEX IF NOT EXISTS \"IDX_payment_session_deleted_at\" ON \"payment_session\" (deleted_at) WHERE deleted_at IS NULL" @@ -624,6 +763,7 @@ "id" ], "composite": false, + "constraint": true, "primary": true, "unique": true } @@ -643,7 +783,8 @@ "deleteRule": "cascade", "updateRule": "cascade" } - } + }, + "nativeEnums": {} }, { "columns": { @@ -788,6 +929,7 @@ "keyName": "IDX_payment_payment_collection_id", "columnNames": [], "composite": false, + "constraint": false, "primary": false, "unique": false, "expression": "CREATE INDEX IF NOT EXISTS \"IDX_payment_payment_collection_id\" ON \"payment\" (payment_collection_id) WHERE deleted_at IS NULL" @@ -796,6 +938,7 @@ "keyName": "IDX_payment_payment_session_id_unique", "columnNames": [], "composite": false, + "constraint": false, "primary": false, "unique": false, "expression": "CREATE UNIQUE INDEX IF NOT EXISTS \"IDX_payment_payment_session_id_unique\" ON \"payment\" (payment_session_id) WHERE deleted_at IS NULL" @@ -804,6 +947,7 @@ "keyName": "IDX_payment_deleted_at", "columnNames": [], "composite": false, + "constraint": false, "primary": false, "unique": false, "expression": "CREATE INDEX IF NOT EXISTS \"IDX_payment_deleted_at\" ON \"payment\" (deleted_at) WHERE deleted_at IS NULL" @@ -812,6 +956,7 @@ "keyName": "IDX_payment_provider_id", "columnNames": [], "composite": false, + "constraint": false, "primary": false, "unique": false, "expression": "CREATE INDEX IF NOT EXISTS \"IDX_payment_provider_id\" ON \"payment\" (provider_id) WHERE deleted_at IS NULL" @@ -820,6 +965,7 @@ "keyName": "IDX_payment_payment_session_id", "columnNames": [], "composite": false, + "constraint": false, "primary": false, "unique": false, "expression": "CREATE INDEX IF NOT EXISTS \"IDX_payment_payment_session_id\" ON \"payment\" (payment_session_id) WHERE deleted_at IS NULL" @@ -830,6 +976,7 @@ "id" ], "composite": false, + "constraint": true, "primary": true, "unique": true } @@ -861,7 +1008,8 @@ "referencedTableName": "public.payment_session", "updateRule": "cascade" } - } + }, + "nativeEnums": {} }, { "columns": { @@ -959,6 +1107,7 @@ "keyName": "IDX_capture_payment_id", "columnNames": [], "composite": false, + "constraint": false, "primary": false, "unique": false, "expression": "CREATE INDEX IF NOT EXISTS \"IDX_capture_payment_id\" ON \"capture\" (payment_id) WHERE deleted_at IS NULL" @@ -967,6 +1116,7 @@ "keyName": "IDX_capture_deleted_at", "columnNames": [], "composite": false, + "constraint": false, "primary": false, "unique": false, "expression": "CREATE INDEX IF NOT EXISTS \"IDX_capture_deleted_at\" ON \"capture\" (deleted_at) WHERE deleted_at IS NULL" @@ -977,6 +1127,7 @@ "id" ], "composite": false, + "constraint": true, "primary": true, "unique": true } @@ -996,7 +1147,8 @@ "deleteRule": "cascade", "updateRule": "cascade" } - } + }, + "nativeEnums": {} }, { "columns": { @@ -1076,6 +1228,7 @@ "keyName": "IDX_refund_reason_deleted_at", "columnNames": [], "composite": false, + "constraint": false, "primary": false, "unique": false, "expression": "CREATE INDEX IF NOT EXISTS \"IDX_refund_reason_deleted_at\" ON \"refund_reason\" (deleted_at) WHERE deleted_at IS NULL" @@ -1086,12 +1239,14 @@ "id" ], "composite": false, + "constraint": true, "primary": true, "unique": true } ], "checks": [], - "foreignKeys": {} + "foreignKeys": {}, + "nativeEnums": {} }, { "columns": { @@ -1207,6 +1362,7 @@ "keyName": "IDX_refund_payment_id", "columnNames": [], "composite": false, + "constraint": false, "primary": false, "unique": false, "expression": "CREATE INDEX IF NOT EXISTS \"IDX_refund_payment_id\" ON \"refund\" (payment_id) WHERE deleted_at IS NULL" @@ -1215,6 +1371,7 @@ "keyName": "IDX_refund_refund_reason_id", "columnNames": [], "composite": false, + "constraint": false, "primary": false, "unique": false, "expression": "CREATE INDEX IF NOT EXISTS \"IDX_refund_refund_reason_id\" ON \"refund\" (refund_reason_id) WHERE deleted_at IS NULL" @@ -1223,6 +1380,7 @@ "keyName": "IDX_refund_deleted_at", "columnNames": [], "composite": false, + "constraint": false, "primary": false, "unique": false, "expression": "CREATE INDEX IF NOT EXISTS \"IDX_refund_deleted_at\" ON \"refund\" (deleted_at) WHERE deleted_at IS NULL" @@ -1233,6 +1391,7 @@ "id" ], "composite": false, + "constraint": true, "primary": true, "unique": true } @@ -1265,7 +1424,9 @@ "deleteRule": "set null", "updateRule": "cascade" } - } + }, + "nativeEnums": {} } - ] + ], + "nativeEnums": {} } diff --git a/packages/modules/payment/src/migrations/Migration20250123122334.ts b/packages/modules/payment/src/migrations/Migration20250123122334.ts new file mode 100644 index 0000000000..d07d2db352 --- /dev/null +++ b/packages/modules/payment/src/migrations/Migration20250123122334.ts @@ -0,0 +1,27 @@ +import { Migration } from '@mikro-orm/migrations'; + +export class Migration20250123122334 extends Migration { + + override async up(): Promise { + this.addSql(`create table if not exists "account_holder" ("id" text not null, "provider_id" text not null, "external_id" text not null, "email" text null, "data" jsonb not null default '{}', "metadata" jsonb null, "created_at" timestamptz not null default now(), "updated_at" timestamptz not null default now(), "deleted_at" timestamptz null, constraint "account_holder_pkey" primary key ("id"));`); + this.addSql(`CREATE INDEX IF NOT EXISTS "IDX_account_holder_deleted_at" ON "account_holder" (deleted_at) WHERE deleted_at IS NULL;`); + this.addSql(`CREATE UNIQUE INDEX IF NOT EXISTS "IDX_account_holder_provider_id_external_id_unique" ON "account_holder" (provider_id, external_id) WHERE deleted_at IS NULL;`); + + this.addSql(`alter table if exists "payment_collection_payment_providers" drop constraint if exists "payment_collection_payment_providers_payment_coll_aa276_foreign";`); + this.addSql(`alter table if exists "payment_collection_payment_providers" drop constraint if exists "payment_collection_payment_providers_payment_provider_id_foreign";`); + + this.addSql(`alter table if exists "payment_collection_payment_providers" add constraint "payment_collection_payment_providers_payment_col_aa276_foreign" foreign key ("payment_collection_id") references "payment_collection" ("id") on update cascade on delete cascade;`); + this.addSql(`alter table if exists "payment_collection_payment_providers" add constraint "payment_collection_payment_providers_payment_pro_2d555_foreign" foreign key ("payment_provider_id") references "payment_provider" ("id") on update cascade on delete cascade;`); + } + + override async down(): Promise { + this.addSql(`drop table if exists "account_holder" cascade;`); + + this.addSql(`alter table if exists "payment_collection_payment_providers" drop constraint if exists "payment_collection_payment_providers_payment_col_aa276_foreign";`); + this.addSql(`alter table if exists "payment_collection_payment_providers" drop constraint if exists "payment_collection_payment_providers_payment_pro_2d555_foreign";`); + + this.addSql(`alter table if exists "payment_collection_payment_providers" add constraint "payment_collection_payment_providers_payment_coll_aa276_foreign" foreign key ("payment_collection_id") references "payment_collection" ("id") on update cascade on delete cascade;`); + this.addSql(`alter table if exists "payment_collection_payment_providers" add constraint "payment_collection_payment_providers_payment_provider_id_foreign" foreign key ("payment_provider_id") references "payment_provider" ("id") on update cascade on delete cascade;`); + } + +} diff --git a/packages/modules/payment/src/models/account-holder.ts b/packages/modules/payment/src/models/account-holder.ts new file mode 100644 index 0000000000..2ec82e1aaf --- /dev/null +++ b/packages/modules/payment/src/models/account-holder.ts @@ -0,0 +1,19 @@ +import { model } from "@medusajs/framework/utils" + +const AccountHolder = model + .define("AccountHolder", { + id: model.id({ prefix: "acchld" }).primaryKey(), + provider_id: model.text(), + external_id: model.text(), + email: model.text().nullable(), + data: model.json().default({}), + metadata: model.json().nullable(), + }) + .indexes([ + { + on: ["provider_id", "external_id"], + unique: true, + }, + ]) + +export default AccountHolder diff --git a/packages/modules/payment/src/models/index.ts b/packages/modules/payment/src/models/index.ts index cc1ebaed79..419e1d3d5e 100644 --- a/packages/modules/payment/src/models/index.ts +++ b/packages/modules/payment/src/models/index.ts @@ -6,3 +6,4 @@ export { default as PaymentProvider } from "./payment-provider" export { default as PaymentSession } from "./payment-session" export { default as Refund } from "./refund" export { default as RefundReason } from "./refund-reason" +export { default as AccountHolder } from "./account-holder" diff --git a/packages/modules/payment/src/providers/system.ts b/packages/modules/payment/src/providers/system.ts index f5901bf449..a79f0a8bdd 100644 --- a/packages/modules/payment/src/providers/system.ts +++ b/packages/modules/payment/src/providers/system.ts @@ -1,8 +1,25 @@ +import crypto from "crypto" + import { - CreatePaymentProviderSession, - PaymentProviderError, - PaymentProviderSessionResponse, + AuthorizePaymentInput, + AuthorizePaymentOutput, + CancelPaymentInput, + CancelPaymentOutput, + CapturePaymentInput, + CapturePaymentOutput, + DeletePaymentInput, + DeletePaymentOutput, + GetPaymentStatusInput, + GetPaymentStatusOutput, + InitiatePaymentInput, + InitiatePaymentOutput, ProviderWebhookPayload, + RefundPaymentInput, + RefundPaymentOutput, + RetrievePaymentInput, + RetrievePaymentOutput, + UpdatePaymentInput, + UpdatePaymentOutput, WebhookActionResult, } from "@medusajs/framework/types" import { @@ -23,53 +40,49 @@ export class SystemProviderService extends AbstractPaymentProvider { } async initiatePayment( - context: CreatePaymentProviderSession - ): Promise { - return { data: {} } + input: InitiatePaymentInput + ): Promise { + return { data: {}, id: crypto.randomUUID() } } async getPaymentStatus( - paymentSessionData: Record - ): Promise { + input: GetPaymentStatusInput + ): Promise { throw new Error("Method not implemented.") } async retrievePayment( - paymentSessionData: Record - ): Promise | PaymentProviderError> { + input: RetrievePaymentInput + ): Promise { return {} } - async authorizePayment(_): Promise< - | PaymentProviderError - | { - status: PaymentSessionStatus - data: PaymentProviderSessionResponse["data"] - } - > { + async authorizePayment( + input: AuthorizePaymentInput + ): Promise { return { data: {}, status: PaymentSessionStatus.AUTHORIZED } } - async updatePayment( - _ - ): Promise { - return { data: {} } as PaymentProviderSessionResponse + async updatePayment(input: UpdatePaymentInput): Promise { + return { data: {} } } - async deletePayment(_): Promise> { - return {} + async deletePayment(input: DeletePaymentInput): Promise { + return { data: {} } } - async capturePayment(_): Promise> { - return {} + async capturePayment( + input: CapturePaymentInput + ): Promise { + return { data: {} } } - async refundPayment(_): Promise> { - return {} + async refundPayment(input: RefundPaymentInput): Promise { + return { data: {} } } - async cancelPayment(_): Promise> { - return {} + async cancelPayment(input: CancelPaymentInput): Promise { + return { data: {} } } async getWebhookActionAndData( diff --git a/packages/modules/payment/src/services/payment-module.ts b/packages/modules/payment/src/services/payment-module.ts index 71791454be..4063883402 100644 --- a/packages/modules/payment/src/services/payment-module.ts +++ b/packages/modules/payment/src/services/payment-module.ts @@ -7,6 +7,7 @@ import { CreatePaymentMethodDTO, CreatePaymentSessionDTO, CreateRefundDTO, + AccountHolderDTO, DAL, FilterablePaymentCollectionProps, FilterablePaymentMethodProps, @@ -31,13 +32,17 @@ import { UpdatePaymentCollectionDTO, UpdatePaymentDTO, UpdatePaymentSessionDTO, + CreateAccountHolderDTO, UpsertPaymentCollectionDTO, WebhookActionResult, + CreateAccountHolderOutput, + InitiatePaymentOutput, } from "@medusajs/framework/types" import { BigNumber, InjectManager, InjectTransactionManager, + isPresent, isString, MathBN, MedusaContext, @@ -48,6 +53,7 @@ import { promiseAll, } from "@medusajs/framework/utils" import { + AccountHolder, Capture, Payment, PaymentCollection, @@ -66,6 +72,7 @@ type InjectedDependencies = { refundService: ModulesSdkTypes.IMedusaInternalService paymentSessionService: ModulesSdkTypes.IMedusaInternalService paymentCollectionService: ModulesSdkTypes.IMedusaInternalService + accountHolderService: ModulesSdkTypes.IMedusaInternalService paymentProviderService: PaymentProviderService } @@ -76,6 +83,7 @@ const generateMethodForModels = { Capture, Refund, RefundReason, + AccountHolder, } export default class PaymentModuleService @@ -86,6 +94,7 @@ export default class PaymentModuleService Capture: { dto: CaptureDTO } Refund: { dto: RefundDTO } RefundReason: { dto: RefundReasonDTO } + AccountHolder: { dto: AccountHolderDTO } }>(generateMethodForModels) implements IPaymentModuleService { @@ -107,6 +116,9 @@ export default class PaymentModuleService typeof PaymentCollection > protected paymentProviderService_: PaymentProviderService + protected accountHolderService_: ModulesSdkTypes.IMedusaInternalService< + typeof AccountHolder + > constructor( { @@ -117,6 +129,7 @@ export default class PaymentModuleService paymentSessionService, paymentProviderService, paymentCollectionService, + accountHolderService, }: InjectedDependencies, protected readonly moduleDeclaration: InternalModuleDeclaration ) { @@ -131,6 +144,7 @@ export default class PaymentModuleService this.paymentSessionService_ = paymentSessionService this.paymentProviderService_ = paymentProviderService this.paymentCollectionService_ = paymentCollectionService + this.accountHolderService_ = accountHolderService } __joinerConfig(): ModuleJoinerConfig { @@ -316,7 +330,7 @@ export default class PaymentModuleService @MedusaContext() sharedContext?: Context ): Promise { let paymentSession: InferEntityType | undefined - let providerPaymentSession: Record | undefined + let providerPaymentSession: InitiatePaymentOutput | undefined try { paymentSession = await this.createPaymentSession_( @@ -328,7 +342,8 @@ export default class PaymentModuleService providerPaymentSession = await this.paymentProviderService_.createSession( input.provider_id, { - context: { ...input.context, session_id: paymentSession!.id }, + context: input.context, + data: { ...input.data, session_id: paymentSession!.id }, amount: input.amount, currency_code: input.currency_code, } @@ -338,15 +353,14 @@ export default class PaymentModuleService await this.paymentSessionService_.update( { id: paymentSession!.id, - data: { ...input.data, ...providerPaymentSession }, + data: { ...input.data, ...providerPaymentSession.data }, }, sharedContext ) )[0] } catch (error) { if (providerPaymentSession) { - await this.paymentProviderService_.deleteSession({ - provider_id: input.provider_id, + await this.paymentProviderService_.deleteSession(input.provider_id, { data: input.data, }) } @@ -420,8 +434,7 @@ export default class PaymentModuleService sharedContext ) - await this.paymentProviderService_.deleteSession({ - provider_id: session.provider_id, + await this.paymentProviderService_.deleteSession(session.provider_id, { data: session.data, }) @@ -460,11 +473,11 @@ export default class PaymentModuleService } let { data, status } = await this.paymentProviderService_.authorizePayment( + session.provider_id, { - provider_id: session.provider_id, data: session.data, - }, - context + context, + } ) if ( @@ -486,8 +499,7 @@ export default class PaymentModuleService sharedContext ) } catch (error) { - await this.paymentProviderService_.cancelPayment({ - provider_id: session.provider_id, + await this.paymentProviderService_.cancelPayment(session.provider_id, { data, }) @@ -509,7 +521,7 @@ export default class PaymentModuleService @InjectTransactionManager() async authorizePaymentSession_( session: InferEntityType, - data: Record, + data: Record | undefined, status: PaymentSessionStatus, @MedusaContext() sharedContext?: Context ): Promise> { @@ -711,15 +723,17 @@ export default class PaymentModuleService isFullyCaptured: boolean, @MedusaContext() sharedContext: Context = {} ) { - const paymentData = await this.paymentProviderService_.capturePayment({ - data: payment.data!, - provider_id: payment.provider_id, - }) + const paymentData = await this.paymentProviderService_.capturePayment( + payment.provider_id, + { + data: payment.data!, + } + ) await this.paymentService_.update( { id: payment.id, - data: paymentData, + data: paymentData.data, captured_at: isFullyCaptured ? new Date() : undefined, }, sharedContext @@ -817,15 +831,15 @@ export default class PaymentModuleService @MedusaContext() sharedContext: Context = {} ) { const paymentData = await this.paymentProviderService_.refundPayment( + payment.provider_id, { data: payment.data!, - provider_id: payment.provider_id, - }, - refund.raw_amount as BigNumberInput + amount: refund.raw_amount as BigNumberInput, + } ) await this.paymentService_.update( - { id: payment.id, data: paymentData }, + { id: payment.id, data: paymentData.data }, sharedContext ) @@ -843,9 +857,8 @@ export default class PaymentModuleService sharedContext ) - await this.paymentProviderService_.cancelPayment({ + await this.paymentProviderService_.cancelPayment(payment.provider_id, { data: payment.data!, - provider_id: payment.provider_id, }) await this.paymentService_.update( @@ -909,6 +922,82 @@ export default class PaymentModuleService ] } + @InjectManager() + async createAccountHolder( + input: CreateAccountHolderDTO, + @MedusaContext() sharedContext?: Context + ): Promise { + if (input.context?.account_holder) { + return input.context.account_holder as AccountHolderDTO + } + + let accountHolder: InferEntityType | undefined + let providerAccountHolder: CreateAccountHolderOutput | undefined + + try { + providerAccountHolder = + await this.paymentProviderService_.createAccountHolder( + input.provider_id, + { context: input.context } + ) + + // This can be empty when either the method is not supported or an account holder wasn't created + if (isPresent(providerAccountHolder)) { + accountHolder = await this.accountHolderService_.create( + { + external_id: providerAccountHolder.id, + email: input.context.customer?.email, + data: providerAccountHolder.data, + provider_id: input.provider_id, + }, + sharedContext + ) + } + } catch (error) { + if (providerAccountHolder) { + await this.paymentProviderService_.deleteAccountHolder( + input.provider_id, + { + context: { + account_holder: providerAccountHolder as { + data: Record + }, + }, + } + ) + } + + if (accountHolder) { + await this.accountHolderService_.delete(accountHolder.id, sharedContext) + } + + throw error + } + + return await this.baseRepository_.serialize(accountHolder) + } + + @InjectManager() + async deleteAccountHolder( + id: string, + @MedusaContext() sharedContext?: Context + ): Promise { + const accountHolder = await this.accountHolderService_.retrieve( + id, + { select: ["id", "provider_id", "external_id", "email", "data"] }, + sharedContext + ) + + await this.accountHolderService_.delete(id, sharedContext) + + await this.paymentProviderService_.deleteAccountHolder( + accountHolder.provider_id, + { + context: { account_holder: accountHolder }, + } + ) + } + @InjectManager() async listPaymentMethods( filters: FilterablePaymentMethodProps, @@ -917,12 +1006,12 @@ export default class PaymentModuleService ): Promise { const res = await this.paymentProviderService_.listPaymentMethods( filters.provider_id, - filters.context + { context: filters.context } ) return res.map((item) => ({ id: item.id, - data: item.data, + data: item.data!, provider_id: filters.provider_id, })) } @@ -936,12 +1025,12 @@ export default class PaymentModuleService const paymentMethods = await this.paymentProviderService_.listPaymentMethods( filters.provider_id, - filters.context + { context: filters.context } ) const normalizedResponse = paymentMethods.map((item) => ({ id: item.id, - data: item.data, + data: item.data!, provider_id: filters.provider_id, })) @@ -950,9 +1039,9 @@ export default class PaymentModuleService // @ts-ignore createPaymentMethods( - data: CreatePaymentCollectionDTO, + data: CreatePaymentMethodDTO, sharedContext?: Context - ): Promise + ): Promise createPaymentMethods( data: CreatePaymentMethodDTO[], @@ -975,7 +1064,7 @@ export default class PaymentModuleService const normalizedResponse = result.map((item, i) => { return { id: item.id, - data: item.data, + data: item.data!, provider_id: input[i].provider_id, } }) diff --git a/packages/modules/payment/src/services/payment-provider.ts b/packages/modules/payment/src/services/payment-provider.ts index 171240c8f1..47fc782eb1 100644 --- a/packages/modules/payment/src/services/payment-provider.ts +++ b/packages/modules/payment/src/services/payment-provider.ts @@ -1,25 +1,36 @@ import { - BigNumberInput, - CreatePaymentProviderSession, + AuthorizePaymentInput, + AuthorizePaymentOutput, + CancelPaymentInput, + CancelPaymentOutput, + CapturePaymentInput, + CapturePaymentOutput, + CreateAccountHolderInput, + CreateAccountHolderOutput, DAL, + DeleteAccountHolderInput, + DeleteAccountHolderOutput, + DeletePaymentInput, + DeletePaymentOutput, + GetPaymentStatusInput, + GetPaymentStatusOutput, + InitiatePaymentInput, + InitiatePaymentOutput, IPaymentProvider, + ListPaymentMethodsInput, + ListPaymentMethodsOutput, Logger, - PaymentMethodResponse, - PaymentProviderAuthorizeResponse, - PaymentProviderContext, - PaymentProviderDataInput, - PaymentProviderError, - PaymentProviderSessionResponse, - PaymentSessionStatus, ProviderWebhookPayload, - SavePaymentMethod, - SavePaymentMethodResponse, - UpdatePaymentProviderSession, + RefundPaymentInput, + RefundPaymentOutput, + SavePaymentMethodInput, + SavePaymentMethodOutput, + UpdatePaymentInput, + UpdatePaymentOutput, WebhookActionResult, } from "@medusajs/framework/types" -import { MedusaError, ModulesSdkUtils } from "@medusajs/framework/utils" +import { ModulesSdkUtils } from "@medusajs/framework/utils" import { PaymentProvider } from "@models" -import { EOL } from "os" type InjectedDependencies = { logger?: Logger @@ -59,135 +70,128 @@ Please make sure that the provider is registered in the container and it is conf async createSession( providerId: string, - sessionInput: CreatePaymentProviderSession - ): Promise { + sessionInput: InitiatePaymentInput + ): Promise { const provider = this.retrieveProvider(providerId) - const paymentResponse = await provider.initiatePayment(sessionInput) - - if (isPaymentProviderError(paymentResponse)) { - this.throwPaymentProviderError(paymentResponse) - } - - return (paymentResponse as PaymentProviderSessionResponse).data + return await provider.initiatePayment(sessionInput) } async updateSession( providerId: string, - sessionInput: UpdatePaymentProviderSession - ): Promise { + sessionInput: UpdatePaymentInput + ): Promise { const provider = this.retrieveProvider(providerId) - const paymentResponse = await provider.updatePayment(sessionInput) - - if (isPaymentProviderError(paymentResponse)) { - this.throwPaymentProviderError(paymentResponse) - } - - return (paymentResponse as PaymentProviderSessionResponse)?.data + return await provider.updatePayment(sessionInput) } - async deleteSession(input: PaymentProviderDataInput): Promise { - const provider = this.retrieveProvider(input.provider_id) - - const error = await provider.deletePayment(input.data) - if (isPaymentProviderError(error)) { - this.throwPaymentProviderError(error) - } + async deleteSession( + providerId: string, + input: DeletePaymentInput + ): Promise { + const provider = this.retrieveProvider(providerId) + return await provider.deletePayment(input) } async authorizePayment( - input: PaymentProviderDataInput, - context: Record - ): Promise<{ data: Record; status: PaymentSessionStatus }> { - const provider = this.retrieveProvider(input.provider_id) - - const res = await provider.authorizePayment(input.data, context) - if (isPaymentProviderError(res)) { - this.throwPaymentProviderError(res) - } - - const { data, status } = res as PaymentProviderAuthorizeResponse - return { data, status } + providerId: string, + input: AuthorizePaymentInput + ): Promise { + const provider = this.retrieveProvider(providerId) + return await provider.authorizePayment(input) } async getStatus( - input: PaymentProviderDataInput - ): Promise { - const provider = this.retrieveProvider(input.provider_id) - return await provider.getPaymentStatus(input.data) + providerId: string, + input: GetPaymentStatusInput + ): Promise { + const provider = this.retrieveProvider(providerId) + return await provider.getPaymentStatus(input) } async capturePayment( - input: PaymentProviderDataInput - ): Promise> { - const provider = this.retrieveProvider(input.provider_id) - - const res = await provider.capturePayment(input.data) - if (isPaymentProviderError(res)) { - this.throwPaymentProviderError(res) - } - - return res as Record + providerId: string, + input: CapturePaymentInput + ): Promise { + const provider = this.retrieveProvider(providerId) + return await provider.capturePayment(input) } - async cancelPayment(input: PaymentProviderDataInput): Promise { - const provider = this.retrieveProvider(input.provider_id) - - const error = await provider.cancelPayment(input.data) - if (isPaymentProviderError(error)) { - this.throwPaymentProviderError(error) - } + async cancelPayment( + providerId: string, + input: CancelPaymentInput + ): Promise { + const provider = this.retrieveProvider(providerId) + return await provider.cancelPayment(input) } async refundPayment( - input: PaymentProviderDataInput, - amount: BigNumberInput - ): Promise> { - const provider = this.retrieveProvider(input.provider_id) + providerId: string, + input: RefundPaymentInput + ): Promise { + const provider = this.retrieveProvider(providerId) + return await provider.refundPayment(input) + } - const res = await provider.refundPayment(input.data, amount) - if (isPaymentProviderError(res)) { - this.throwPaymentProviderError(res) + async createAccountHolder( + providerId: string, + input: CreateAccountHolderInput + ): Promise { + const provider = this.retrieveProvider(providerId) + if (!provider.createAccountHolder) { + this.#logger.warn( + `Provider ${providerId} does not support creating account holders` + ) + return {} as unknown as CreateAccountHolderOutput } - return res as Record + return await provider.createAccountHolder(input) + } + + async deleteAccountHolder( + providerId: string, + input: DeleteAccountHolderInput + ): Promise { + const provider = this.retrieveProvider(providerId) + if (!provider.deleteAccountHolder) { + this.#logger.warn( + `Provider ${providerId} does not support deleting account holders` + ) + return {} + } + + return await provider.deleteAccountHolder(input) } async listPaymentMethods( providerId: string, - context: PaymentProviderContext - ): Promise { + input: ListPaymentMethodsInput + ): Promise { const provider = this.retrieveProvider(providerId) if (!provider.listPaymentMethods) { - throw new MedusaError( - MedusaError.Types.INVALID_DATA, + this.#logger.warn( `Provider ${providerId} does not support listing payment methods` ) + return [] } - return await provider.listPaymentMethods(context) + return await provider.listPaymentMethods(input) } async savePaymentMethod( providerId: string, - input: SavePaymentMethod - ): Promise { + input: SavePaymentMethodInput + ): Promise { const provider = this.retrieveProvider(providerId) if (!provider.savePaymentMethod) { - throw new MedusaError( - MedusaError.Types.INVALID_DATA, + this.#logger.warn( `Provider ${providerId} does not support saving payment methods` ) + return {} as unknown as SavePaymentMethodOutput } - const res = await provider.savePaymentMethod(input) - - if (isPaymentProviderError(res)) { - this.throwPaymentProviderError(res) - } - - return res as SavePaymentMethodResponse + return await provider.savePaymentMethod(input) } async getWebhookActionAndData( @@ -198,22 +202,4 @@ Please make sure that the provider is registered in the container and it is conf return await provider.getWebhookActionAndData(data) } - - private throwPaymentProviderError(errObj: PaymentProviderError) { - throw new MedusaError( - MedusaError.Types.INVALID_DATA, - `${errObj.error}${errObj.detail ? `:${EOL}${errObj.detail}` : ""}`, - errObj.code - ) - } -} - -function isPaymentProviderError(obj: any): obj is PaymentProviderError { - return ( - obj && - typeof obj === "object" && - "error" in obj && - "code" in obj && - "detail" in obj - ) } diff --git a/packages/modules/providers/payment-stripe/src/core/stripe-base.ts b/packages/modules/providers/payment-stripe/src/core/stripe-base.ts index 8ac308e054..2d0f7bb080 100644 --- a/packages/modules/providers/payment-stripe/src/core/stripe-base.ts +++ b/packages/modules/providers/payment-stripe/src/core/stripe-base.ts @@ -1,15 +1,33 @@ import Stripe from "stripe" import { - CreatePaymentProviderSession, - PaymentMethodResponse, - PaymentProviderContext, - PaymentProviderError, - PaymentProviderSessionResponse, + AuthorizePaymentInput, + AuthorizePaymentOutput, + CancelPaymentInput, + CancelPaymentOutput, + CapturePaymentInput, + CapturePaymentOutput, + CreateAccountHolderInput, + CreateAccountHolderOutput, + DeleteAccountHolderInput, + DeleteAccountHolderOutput, + DeletePaymentInput, + DeletePaymentOutput, + GetPaymentStatusInput, + GetPaymentStatusOutput, + InitiatePaymentInput, + InitiatePaymentOutput, + ListPaymentMethodsInput, + ListPaymentMethodsOutput, ProviderWebhookPayload, - SavePaymentMethod, - SavePaymentMethodResponse, - UpdatePaymentProviderSession, + RefundPaymentInput, + RefundPaymentOutput, + RetrievePaymentInput, + RetrievePaymentOutput, + SavePaymentMethodInput, + SavePaymentMethodOutput, + UpdatePaymentInput, + UpdatePaymentOutput, WebhookActionResult, } from "@medusajs/framework/types" import { @@ -95,62 +113,55 @@ abstract class StripeBase extends AbstractPaymentProvider { return res } - async getPaymentStatus( - paymentSessionData: Record - ): Promise { - const id = paymentSessionData.id as string + async getPaymentStatus({ + data, + }: GetPaymentStatusInput): Promise { + const id = data?.id as string + if (!id) { + throw this.buildError( + "No payment intent ID provided while getting payment status", + new Error("No payment intent ID provided") + ) + } + const paymentIntent = await this.stripe_.paymentIntents.retrieve(id) switch (paymentIntent.status) { case "requires_payment_method": case "requires_confirmation": case "processing": - return PaymentSessionStatus.PENDING + return { status: PaymentSessionStatus.PENDING } case "requires_action": - return PaymentSessionStatus.REQUIRES_MORE + return { status: PaymentSessionStatus.REQUIRES_MORE } case "canceled": - return PaymentSessionStatus.CANCELED + return { status: PaymentSessionStatus.CANCELED } case "requires_capture": - return PaymentSessionStatus.AUTHORIZED + return { status: PaymentSessionStatus.AUTHORIZED } case "succeeded": - return PaymentSessionStatus.CAPTURED + return { status: PaymentSessionStatus.CAPTURED } default: - return PaymentSessionStatus.PENDING + return { status: PaymentSessionStatus.PENDING } } } - async initiatePayment( - input: CreatePaymentProviderSession - ): Promise { - const { email, extra, session_id, customer } = input.context - const { currency_code, amount } = input - - const additionalParameters = this.normalizePaymentIntentParameters(extra) + async initiatePayment({ + currency_code, + amount, + data, + context, + }: InitiatePaymentInput): Promise { + const additionalParameters = this.normalizePaymentIntentParameters(data) const intentRequest: Stripe.PaymentIntentCreateParams = { amount: getSmallestUnit(amount, currency_code), currency: currency_code, - metadata: { session_id: session_id! }, + metadata: { session_id: data?.session_id as string }, ...additionalParameters, } - if (customer?.metadata?.stripe_id) { - intentRequest.customer = customer.metadata.stripe_id as string - } else { - let stripeCustomer - try { - stripeCustomer = await this.stripe_.customers.create({ - email, - }) - } catch (e) { - return this.buildError( - "An error occurred in initiatePayment when creating a Stripe customer", - e - ) - } - - intentRequest.customer = stripeCustomer.id - } + intentRequest.customer = context?.account_holder?.data?.id as + | string + | undefined let sessionData try { @@ -158,158 +169,215 @@ abstract class StripeBase extends AbstractPaymentProvider { intentRequest )) as unknown as Record } catch (e) { - return this.buildError( + throw this.buildError( "An error occurred in InitiatePayment during the creation of the stripe payment intent", e ) } return { + id: sessionData.id, data: sessionData, - // TODO: REVISIT - // update_requests: customer?.metadata?.stripe_id - // ? undefined - // : { - // customer_metadata: { - // stripe_id: intentRequest.customer, - // }, - // }, } } async authorizePayment( - paymentSessionData: Record, - context: Record - ): Promise< - | PaymentProviderError - | { - status: PaymentSessionStatus - data: PaymentProviderSessionResponse["data"] - } - > { - const status = await this.getPaymentStatus(paymentSessionData) - return { data: paymentSessionData, status } + input: AuthorizePaymentInput + ): Promise { + const statusResponse = await this.getPaymentStatus(input) + return statusResponse } - async cancelPayment( - paymentSessionData: Record - ): Promise { + async cancelPayment({ + data, + }: CancelPaymentInput): Promise { try { - const id = paymentSessionData.id as string + const id = data?.id as string if (!id) { - return paymentSessionData + return { data: data } } - return (await this.stripe_.paymentIntents.cancel( - id - )) as unknown as PaymentProviderSessionResponse["data"] + const res = await this.stripe_.paymentIntents.cancel(id) + return { data: res as unknown as Record } } catch (error) { if (error.payment_intent?.status === ErrorIntentStatus.CANCELED) { - return error.payment_intent + return { data: error.payment_intent } } - return this.buildError("An error occurred in cancelPayment", error) + throw this.buildError("An error occurred in cancelPayment", error) } } - async capturePayment( - paymentSessionData: Record - ): Promise { - const id = paymentSessionData.id as string + async capturePayment({ + data, + }: CapturePaymentInput): Promise { + const id = data?.id as string + try { const intent = await this.stripe_.paymentIntents.capture(id) - return intent as unknown as PaymentProviderSessionResponse["data"] + return { data: intent as unknown as Record } } catch (error) { if (error.code === ErrorCodes.PAYMENT_INTENT_UNEXPECTED_STATE) { if (error.payment_intent?.status === ErrorIntentStatus.SUCCEEDED) { - return error.payment_intent + return { data: error.payment_intent } } } - return this.buildError("An error occurred in capturePayment", error) + throw this.buildError("An error occurred in capturePayment", error) } } - async deletePayment( - paymentSessionData: Record - ): Promise { - return await this.cancelPayment(paymentSessionData) + async deletePayment(input: DeletePaymentInput): Promise { + return await this.cancelPayment(input) } - async refundPayment( - paymentSessionData: Record, - refundAmount: number - ): Promise { - const id = paymentSessionData.id as string + async refundPayment({ + amount, + data, + }: RefundPaymentInput): Promise { + const id = data?.id as string + if (!id) { + throw this.buildError( + "No payment intent ID provided while refunding payment", + new Error("No payment intent ID provided") + ) + } try { - const { currency } = paymentSessionData + const currencyCode = data?.currency as string await this.stripe_.refunds.create({ - amount: getSmallestUnit(refundAmount, currency as string), + amount: getSmallestUnit(amount, currencyCode), payment_intent: id as string, }) } catch (e) { - return this.buildError("An error occurred in refundPayment", e) + throw this.buildError("An error occurred in refundPayment", e) } - return paymentSessionData + return { data } } - async retrievePayment( - paymentSessionData: Record - ): Promise { + async retrievePayment({ + data, + }: RetrievePaymentInput): Promise { try { - const id = paymentSessionData.id as string + const id = data?.id as string const intent = await this.stripe_.paymentIntents.retrieve(id) intent.amount = getAmountFromSmallestUnit(intent.amount, intent.currency) - return intent as unknown as PaymentProviderSessionResponse["data"] + return { data: intent as unknown as Record } } catch (e) { - return this.buildError("An error occurred in retrievePayment", e) + throw this.buildError("An error occurred in retrievePayment", e) } } - async updatePayment( - input: UpdatePaymentProviderSession - ): Promise { - const { context, data, currency_code, amount } = input - + async updatePayment({ + data, + currency_code, + amount, + }: UpdatePaymentInput): Promise { const amountNumeric = getSmallestUnit(amount, currency_code) + if (isPresent(amount) && data?.amount === amountNumeric) { + return { data } + } - const stripeId = context.customer?.metadata?.stripe_id + try { + const id = data?.id as string + const sessionData = (await this.stripe_.paymentIntents.update(id, { + amount: amountNumeric, + })) as unknown as Record - if (stripeId !== data.customer) { - return await this.initiatePayment(input) - } else { - if (isPresent(amount) && data.amount === amountNumeric) { - return { data } - } - - try { - const id = data.id as string - const sessionData = (await this.stripe_.paymentIntents.update(id, { - amount: amountNumeric, - })) as unknown as PaymentProviderSessionResponse["data"] - - return { data: sessionData } - } catch (e) { - return this.buildError("An error occurred in updatePayment", e) - } + return { data: sessionData } + } catch (e) { + throw this.buildError("An error occurred in updatePayment", e) } } - async listPaymentMethods( - context: PaymentProviderContext - ): Promise { - const customerId = context.customer?.metadata?.stripe_id - if (!customerId) { + async createAccountHolder({ + context, + }: CreateAccountHolderInput): Promise { + const { account_holder, customer } = context + + if (account_holder?.data?.id) { + return { id: account_holder.data.id as string } + } + + if (!customer) { + throw this.buildError( + "No customer in context", + new Error("No customer provided while creating account holder") + ) + } + + const shipping = customer.billing_address + ? ({ + address: { + city: customer.billing_address.city, + country: customer.billing_address.country_code, + line1: customer.billing_address.address_1, + line2: customer.billing_address.address_2, + postal_code: customer.billing_address.postal_code, + state: customer.billing_address.province, + }, + } as Stripe.CustomerCreateParams.Shipping) + : undefined + + try { + const stripeCustomer = await this.stripe_.customers.create({ + email: customer.email, + name: + customer.company_name || + `${customer.first_name ?? ""} ${customer.last_name ?? ""}`.trim() || + undefined, + phone: customer.phone as string | undefined, + ...shipping, + }) + + return { + id: stripeCustomer.id, + data: stripeCustomer as unknown as Record, + } + } catch (e) { + throw this.buildError( + "An error occurred in createAccountHolder when creating a Stripe customer", + e + ) + } + } + + async deleteAccountHolder({ + context, + }: DeleteAccountHolderInput): Promise { + const { account_holder } = context + const accountHolderId = account_holder?.data?.id as string | undefined + if (!accountHolderId) { + throw this.buildError( + "No account holder in context", + new Error("No account holder provided while deleting account holder") + ) + } + + try { + await this.stripe_.customers.del(accountHolderId) + return {} + } catch (e) { + throw this.buildError("An error occurred in deleteAccountHolder", e) + } + } + + async listPaymentMethods({ + context, + }: ListPaymentMethodsInput): Promise { + const accountHolderId = context?.account_holder?.data?.id as + | string + | undefined + if (!accountHolderId) { return [] } const paymentMethods = await this.stripe_.customers.listPaymentMethods( - customerId as string, + accountHolderId, // In order to keep the interface simple, we just list the maximum payment methods, which should be enough in almost all cases. // We can always extend the interface to allow additional filtering, if necessary. { limit: 100 } @@ -321,21 +389,23 @@ abstract class StripeBase extends AbstractPaymentProvider { })) } - async savePaymentMethod( - input: SavePaymentMethod - ): Promise { - const { context, data } = input - const customer = context?.customer + async savePaymentMethod({ + context, + data, + }: SavePaymentMethodInput): Promise { + const accountHolderId = context?.account_holder?.data?.id as + | string + | undefined - if (!customer?.metadata?.stripe_id) { - return this.buildError( + if (!accountHolderId) { + throw this.buildError( "Account holder not set while saving a payment method", new Error("Missing account holder") ) } const resp = await this.stripe_.setupIntents.create({ - customer: customer.metadata.stripe_id as string, + customer: accountHolderId, ...data, }) @@ -397,18 +467,15 @@ abstract class StripeBase extends AbstractPaymentProvider { this.options_.webhookSecret ) } - protected buildError(message: string, error: Error): PaymentProviderError { + protected buildError(message: string, error: Error): Error { const errorDetails = "raw" in error ? (error.raw as Stripe.StripeRawError) : error - return { - error: `${message}: ${error.message}`, - code: "code" in errorDetails ? errorDetails.code : "unknown", - detail: - "detail" in errorDetails - ? `${error.message}: ${errorDetails.detail}` - : error.message, - } + return new Error( + `${message}: ${error.message}. ${ + "detail" in errorDetails ? errorDetails.detail : "" + }`.trim() + ) } }