diff --git a/packages/core/core-flows/src/definition/cart/steps/index.ts b/packages/core/core-flows/src/definition/cart/steps/index.ts index 9b18ae517019a..896597ae90dea 100644 --- a/packages/core/core-flows/src/definition/cart/steps/index.ts +++ b/packages/core/core-flows/src/definition/cart/steps/index.ts @@ -5,6 +5,7 @@ export * from "./create-line-item-adjustments" export * from "./create-line-items" export * from "./create-order-from-cart" export * from "./create-shipping-method-adjustments" +export * from "./create-payment-collection" export * from "./find-one-or-any-region" export * from "./find-or-create-customer" export * from "./find-sales-channel" 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 00f2764584dc0..82feeb12b8ef3 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 @@ -11,8 +11,8 @@ interface StepInput { provider_id: string amount: BigNumberInput currency_code: string + provider_token?: string context?: PaymentProviderContext - data?: Record } export const createPaymentSessionStepId = "create-payment-session" @@ -27,9 +27,9 @@ export const createPaymentSessionStep = createStep( input.payment_collection_id, { provider_id: input.provider_id, + provider_token: input.provider_token, currency_code: input.currency_code, amount: input.amount, - data: input.data ?? {}, context: input.context, } ) diff --git a/packages/core/core-flows/src/payment-collection/steps/delete-payment-sessions.ts b/packages/core/core-flows/src/payment-collection/steps/delete-payment-sessions.ts index 03413ccbaf2a4..8daf6e620b721 100644 --- a/packages/core/core-flows/src/payment-collection/steps/delete-payment-sessions.ts +++ b/packages/core/core-flows/src/payment-collection/steps/delete-payment-sessions.ts @@ -80,7 +80,6 @@ export const deletePaymentSessionsStep = createStep( provider_id: paymentSession.provider_id, currency_code: paymentSession.currency_code, amount: paymentSession.amount, - data: paymentSession.data ?? {}, context: paymentSession.context, } 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 f2176f40d87a8..2ee8b76709af4 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,4 +1,9 @@ -import { PaymentProviderContext, PaymentSessionDTO } from "@medusajs/types" +import { + BigNumberInput, + PaymentProviderContext, + PaymentSessionDTO, +} from "@medusajs/types" +import { MathBN, PaymentSessionStatus } from "@medusajs/utils" import { WorkflowData, createWorkflow, @@ -12,8 +17,10 @@ import { deletePaymentSessionsWorkflow } from "./delete-payment-sessions" interface WorkflowInput { payment_collection_id: string provider_id: string + provider_token?: string data?: Record context?: PaymentProviderContext + amount?: BigNumberInput } export const createPaymentSessionsWorkflowId = "create-payment-sessions" @@ -22,7 +29,13 @@ export const createPaymentSessionsWorkflow = createWorkflow( (input: WorkflowData): WorkflowData => { const paymentCollection = useRemoteQueryStep({ entry_point: "payment_collection", - fields: ["id", "amount", "currency_code", "payment_sessions.*"], + fields: [ + "id", + "raw_amount", + "raw_authorized_amount", + "currency_code", + "payment_sessions.*", + ], variables: { id: input.payment_collection_id }, list: false, }) @@ -30,12 +43,17 @@ export const createPaymentSessionsWorkflow = createWorkflow( const paymentSessionInput = transform( { paymentCollection, input }, (data) => { + const balance = MathBN.sub( + data.paymentCollection.raw_amount, + data.paymentCollection.raw_authorized_amount || 0 + ) return { payment_collection_id: data.input.payment_collection_id, provider_id: data.input.provider_id, + provider_token: data.input.provider_token, data: data.input.data, context: data.input.context, - amount: data.paymentCollection.amount, + amount: MathBN.min(data.input.amount || balance, balance), currency_code: data.paymentCollection.currency_code, } } @@ -46,15 +64,14 @@ export const createPaymentSessionsWorkflow = createWorkflow( (data) => { return { ids: - data.paymentCollection?.payment_sessions?.map((ps) => ps.id) || [], + data.paymentCollection?.payment_sessions + ?.filter((ps) => ps.status !== PaymentSessionStatus.AUTHORIZED) + ?.map((ps) => ps.id) || [], } } ) - // Note: We are deleting an existing active session before creating a new one - // for a payment collection as we don't support split payments at the moment. - // When we are ready to accept split payments, this along with other workflows - // need to be handled correctly + // Note: We are deleting all existing non-authorized session before creating a new one const [created] = parallelize( createPaymentSessionStep(paymentSessionInput), deletePaymentSessionsWorkflow.runAsStep({ diff --git a/packages/core/core-flows/src/payment/steps/capture-payment.ts b/packages/core/core-flows/src/payment/steps/capture-payment.ts index c339c615fb274..57ddf5d1391fc 100644 --- a/packages/core/core-flows/src/payment/steps/capture-payment.ts +++ b/packages/core/core-flows/src/payment/steps/capture-payment.ts @@ -16,7 +16,10 @@ export const capturePaymentStep = createStep( ModuleRegistrationName.PAYMENT ) - const payment = await paymentModule.capturePayment(input) + const payment = await paymentModule.capturePayment(input.payment_id, { + amount: input.amount, + captured_by: input.captured_by, + }) return new StepResponse(payment) } diff --git a/packages/core/core-flows/src/payment/steps/refund-payment.ts b/packages/core/core-flows/src/payment/steps/refund-payment.ts index 6d18a4d6981ab..59355331a9415 100644 --- a/packages/core/core-flows/src/payment/steps/refund-payment.ts +++ b/packages/core/core-flows/src/payment/steps/refund-payment.ts @@ -4,7 +4,7 @@ import { StepResponse, createStep } from "@medusajs/workflows-sdk" type StepInput = { payment_id: string - created_by?: string + refunded_by?: string amount?: BigNumberInput } @@ -16,7 +16,10 @@ export const refundPaymentStep = createStep( ModuleRegistrationName.PAYMENT ) - const payment = await paymentModule.refundPayment(input) + const payment = await paymentModule.refundPayment(input.payment_id, { + amount: input.amount, + refunded_by: input.refunded_by, + }) return new StepResponse(payment) } diff --git a/packages/core/js-sdk/src/store/index.ts b/packages/core/js-sdk/src/store/index.ts index 78ec553880892..cc460fa06a059 100644 --- a/packages/core/js-sdk/src/store/index.ts +++ b/packages/core/js-sdk/src/store/index.ts @@ -313,6 +313,22 @@ export class Store { query, }) }, + + addPaymentSession: async ( + paymentCollectionId: string, + body: Record, + query?: SelectParams, + headers?: ClientHeaders + ) => { + return this.client.fetch<{ + payment_collection: HttpTypes.StorePaymentCollection + }>(`/store/payment-collections/${paymentCollectionId}/payment-sessions`, { + method: "POST", + headers, + body, + query, + }) + }, } public order = { diff --git a/packages/core/types/src/payment/common.ts b/packages/core/types/src/payment/common.ts index 2dbc248c36862..7ff267cf92d4c 100644 --- a/packages/core/types/src/payment/common.ts +++ b/packages/core/types/src/payment/common.ts @@ -4,19 +4,24 @@ import { BigNumberValue } from "../totals" /* ********** PAYMENT COLLECTION ********** */ export type PaymentCollectionStatus = - | "not_paid" - | "awaiting" + | "pending" + | "paid" + | "partially_paid" | "authorized" | "partially_authorized" - | "canceled" + | "refunded" + | "partially_refunded" export type PaymentSessionStatus = | "authorized" | "captured" + | "partially_captured" + | "refunded" + | "partially_refunded" | "pending" | "requires_more" - | "error" | "canceled" + | "processing" /** * The payment collection details. diff --git a/packages/core/types/src/payment/mutations.ts b/packages/core/types/src/payment/mutations.ts index bfc0e7a1e257c..205e579d07839 100644 --- a/packages/core/types/src/payment/mutations.ts +++ b/packages/core/types/src/payment/mutations.ts @@ -186,11 +186,6 @@ export interface CreateCaptureDTO { */ amount?: BigNumberInput - /** - * The associated payment's ID. - */ - payment_id: string - /** * Who captured the payment. For example, * a user's ID. @@ -207,16 +202,11 @@ export interface CreateRefundDTO { */ amount?: BigNumberInput - /** - * The associated payment's ID. - */ - payment_id: string - /** * Who refunded the payment. For example, * a user's ID. */ - created_by?: string + refunded_by?: string } /** @@ -228,6 +218,11 @@ export interface CreatePaymentSessionDTO { */ provider_id: string + /** + * The provider's payment method token + */ + provider_token?: string + /** * The ISO 3 character currency code of the payment session. */ @@ -238,11 +233,6 @@ export interface CreatePaymentSessionDTO { */ amount: BigNumberInput - /** - * Necessary data for the associated payment provider to process the payment. - */ - data: Record - /** * Necessary context data for the associated payment provider. */ @@ -259,19 +249,14 @@ export interface UpdatePaymentSessionDTO { id: string /** - * Necessary data for the associated payment provider to process the payment. + * The provider's payment method token */ - data: Record - - /** - * The ISO 3 character currency code. - */ - currency_code: string + provider_token?: string /** * The amount to be authorized. */ - amount: BigNumberInput + amount?: BigNumberInput /** * Necessary context data for the associated payment provider. @@ -279,6 +264,16 @@ export interface UpdatePaymentSessionDTO { context?: PaymentProviderContext } +/** + * The attributes to authorize in a payment session. + */ +export interface AuthorizePaymentSessionDTO { + /** + * The provider token to authorize payment session + */ + provider_token?: string +} + /** * The payment provider to be created. */ diff --git a/packages/core/types/src/payment/provider.ts b/packages/core/types/src/payment/provider.ts index 9d17846cb28ec..91c1ba39998da 100644 --- a/packages/core/types/src/payment/provider.ts +++ b/packages/core/types/src/payment/provider.ts @@ -10,25 +10,16 @@ import { ProviderWebhookPayload } from "./mutations" export type PaymentAddressDTO = Partial /** - * The customer associated with the payment. + * The customer of the payment. */ export type PaymentCustomerDTO = Partial -/** - * Normalized events from payment provider to internal payment module events. - */ -export type PaymentActions = - | "authorized" - | "captured" - | "failed" - | "not_supported" - /** * @interface * - * Context data provided to the payment provider when authorizing a payment session. + * Context data provided to the payment provider. */ -export type PaymentProviderContext = { +export type PaymentProviderContext = Record & { /** * The payment's billing address. */ @@ -40,26 +31,30 @@ export type PaymentProviderContext = { email?: string /** - * The ID of payment session the provider payment is associated with. + * The associated payment session's ID. */ session_id?: string /** - * The customer associated with this payment. + * The associated cart's ID. */ - customer?: PaymentCustomerDTO + cart_id?: string /** - * The extra fields specific to the provider session. + * The associated order's ID. */ - extra?: Record + order_id?: string + + /** + * The associated customer detail + */ + customer?: PaymentCustomerDTO } /** * @interface * - * The data used initiate a payment in a provider when a payment - * session is created. + * The data used initiate a payment in a provider */ export type CreatePaymentProviderSession = { /** @@ -76,81 +71,125 @@ export type CreatePaymentProviderSession = { * The ISO 3 character currency code. */ currency_code: string + + /* + * The payment method token + */ + token?: string } /** * @interface * - * The attributes to update a payment related to a payment session in a provider. + * The attributes to update a payment in a provider. */ export type UpdatePaymentProviderSession = { - /** - * A payment's context. - */ - context: PaymentProviderContext - /** * The `data` field of the payment session. */ data: Record /** - * The payment session's amount. + * A context necessary for the payment provider. */ - amount: BigNumberInput + context?: PaymentProviderContext /** - * The ISO 3 character code of the payment session. + * The amount to be authorized. */ - currency_code: string + amount?: BigNumberInput + + /* + * The payment method token + */ + token?: string } /** * @interface * - * The response of operations on a payment. + * The attributes to authorize a payment in a provider. */ -export type PaymentProviderSessionResponse = { +export type AuthorizePaymentProviderSession = { /** - * 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 `data` field of the payment session. */ data: Record + + /* + * The payment method token + */ + token?: string } /** * @interface * - * The successful result of authorizing a payment session using a payment provider. + * The response of operations on a payment. */ -export type PaymentProviderAuthorizeResponse = { +export type PaymentProviderSessionResponse = { /** * 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. + * The captured amount of the payment */ - data: PaymentProviderSessionResponse["data"] -} + capturedAmount: BigNumberValue -/** - * @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. + * The refunded amount of the payment */ - provider_id: string + refundedAmount: BigNumberValue /** - * The data to be passed to the provider. + * 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. */ data: Record + + /** + * A context necessary for the payment provider. + */ + context: PaymentProviderContext + + /** + * The related event or mutation occurred on a payment. + */ + event?: { + /** + * Event type that related to changes in provider payment + */ + type: "authorize" | "capture" | "refund" | "cancel" + + /** + * The event's details. + */ + detail?: { + /** + * The amount to be changed + */ + amount?: BigNumberValue + + /** + * Who captured the payment. For example, + * a user's ID. + */ + captured_by?: string + + /** + * Who refunded the payment. For example, + * a user's ID. + */ + refunded_by?: string + + /** + * The reason to be canceled + */ + reason?: string + } + } } /** @@ -173,47 +212,6 @@ export interface PaymentProviderError { detail?: any } -/** - * @interface - * - * The details of an action to be performed as a result of a received webhook event. - */ -export type WebhookActionData = { - /** - * The associated payment session's ID. - */ - session_id: string - - /** - * The amount to be captured or authorized (based on the action's type.) - */ - amount: BigNumberValue -} - -/** - * @interface - * - * The actions that the payment provider informs the Payment Module to perform. - */ -export type WebhookActionResult = - | { - /** - * Received an event that is not processable. - */ - action: "not_supported" - } - | { - /** - * Normalized events from payment provider to internal payment module events. - */ - action: PaymentActions - - /** - * The webhook action's details. - */ - data: WebhookActionData - } - export interface IPaymentProvider { /** * @ignore @@ -228,8 +226,7 @@ export interface IPaymentProvider { * For example, in the Stripe provider, this method is used to create a Payment Intent for the customer. * * @param {CreatePaymentProviderSession} data - The data necessary to initiate the payment. - * @returns {Promise} Either the payment's data, which is stored in the `data` field - * of the payment session, or an error object. + * @returns {Promise} Either the payment's status and data or an error object. */ initiatePayment( data: CreatePaymentProviderSession @@ -238,22 +235,22 @@ export interface IPaymentProvider { /** * This method is used to update a payment associated with a session in the third-party provider. * - * @param {UpdatePaymentProviderSession} context - The data related to the update. - * @returns {Promise} Either the payment's data or an error object. + * @param {UpdatePaymentProviderSession} data - The data related to the update. + * @returns {Promise} Either the payment's status and data or an error object. */ updatePayment( - context: UpdatePaymentProviderSession + data: UpdatePaymentProviderSession ): Promise /** * This method is called before a payment session is deleted. It's used to perform any actions necessary before the deletion. * * @param {Record} paymentSessionData - The `data` field of the Payment Session. - * @returns {Promise} Either an error object or an empty object. + * @returns {Promise} Either an error object or null if successful. */ deletePayment( paymentSessionData: Record - ): Promise + ): Promise /** * This method is called when a payment session should be authorized. @@ -262,15 +259,12 @@ export interface IPaymentProvider { * Refer to [this guide](https://docs.medusajs.com/experimental/payment/payment-flow/#3-authorize-payment-session) * to learn more about how this fits into the payment flow and how to handle required actions. * - * @param {Record} paymentSessionData - The `data` field of the payment session. - * @param {Record} context - The context of the authorization. - * @returns {Promise} The authorization details or an error object. If - * the authorization details are returned, the `data` and `status` field are set in the associated payment session. + * @param {AuthorizePaymentProviderSession} data - The data related to authorize. + * @returns {Promise} Either the payment's status and data or an error object. */ authorizePayment( - paymentSessionData: Record, - context: Record - ): Promise + data: AuthorizePaymentProviderSession + ): Promise /** * This method is called when a payment should be captured. The payment is captured in one of the following scenarios: @@ -281,62 +275,52 @@ export interface IPaymentProvider { * * In this method, you can interact with the third-party provider and perform any actions necessary to capture the payment. * - * @param {Record} paymentSessionData - The `data` field of the payment. - * @returns {Promise} Either an error object or a value that's stored in the `data` field of the payment. + * @param {Record} paymentSessionData - The `data` field of the Payment Session. + * @param {BigNumberInput} captureAmount - The amount to capture. + * @returns {Promise} Either the payment's status and data or an error object. */ capturePayment( - paymentSessionData: Record - ): Promise + paymentSessionData: Record, + captureAmount?: BigNumberInput + ): Promise /** * This method is called when a payment should be refunded. This is typically triggered manually by the merchant. * * In this method, you can interact with the third-party provider and perform any actions necessary to refund the payment. * - * @param {Record} paymentSessionData - The `data` field of a Payment. + * @param {Record} paymentSessionData - The `data` field of the Payment Session. * @param {BigNumberInput} refundAmount - The amount to refund. - * @returns {Promise} Either an error object or an object that's stored in the `data` field of the payment. + * @returns {Promise} Either the payment's status and data or an error object. */ refundPayment( paymentSessionData: Record, - refundAmount: BigNumberInput - ): Promise + refundAmount?: BigNumberInput + ): Promise /** * This method is used to provide a uniform way of retrieving the payment information from the third-party provider. * * For example, in Stripe’s payment provider this method is used to retrieve the payment intent details from Stripe. * - * @param {Record} paymentSessionData - - * The `data` field of a payment session. Make sure to store in the `data` field any necessary data that would allow you to retrieve the payment data from the third-party provider. - * @returns {Promise} Either an error object or the payment's data retrieved from a third-party provider. + * @param {Record} paymentSessionData - The `data` field of the Payment Session. + * @returns {Promise} Either the payment's status and data or an error object. */ retrievePayment( paymentSessionData: Record - ): Promise + ): Promise /** * This method is called when a payment is canceled. * * In this method, you can interact with the third-party provider and perform any actions necessary to cancel the payment. * - * @param {Record} paymentSessionData - The `data` field of the payment. - * @returns {Promise} Either an error object or a value that's stored in the `data` field of the payment. + * @param {Record} paymentSessionData - The `data` field of the Payment Session. + * @returns {Promise} Either the payment's status and data or an error object. */ cancelPayment( paymentSessionData: Record - ): Promise - - /** - * This method is used to get the status of a payment or a payment session. - * - * @param {Record} paymentSessionData - - * The `data` field of a payment as a parameter. You can use this data to interact with the third-party provider to check the status of the payment if necessary. - * @returns {Promise} The status of the payment or payment session. - */ - getPaymentStatus( - paymentSessionData: Record - ): Promise + ): Promise /** * The method is called when a webhook event is received for this provider. @@ -349,5 +333,5 @@ export interface IPaymentProvider { */ getWebhookActionAndData( data: ProviderWebhookPayload["payload"] - ): Promise + ): Promise } diff --git a/packages/core/types/src/payment/service.ts b/packages/core/types/src/payment/service.ts index a3413e7bab577..066197ddbf043 100644 --- a/packages/core/types/src/payment/service.ts +++ b/packages/core/types/src/payment/service.ts @@ -16,6 +16,7 @@ import { RefundDTO, } from "./common" import { + AuthorizePaymentSessionDTO, CreateCaptureDTO, CreatePaymentCollectionDTO, CreatePaymentSessionDTO, @@ -424,7 +425,7 @@ export interface IPaymentModuleService extends IModuleService { * provider_id: "stripe", * currency_code: "usd", * amount: 3000, - * data: {}, + * context: {}, * } * ) */ @@ -460,7 +461,7 @@ export interface IPaymentModuleService extends IModuleService { * * @param {string} id - The ID of the payment session. * @param {Context} sharedContext - A context used to share resources, such as transaction manager, between the application and the module. - * @returns {Promise} Resolves whent the payment session is deleted successfully. + * @returns {Promise} Resolves when the payment session is deleted successfully. * * @example * await paymentModuleService.deletePaymentSession("payses_123") @@ -473,20 +474,16 @@ export interface IPaymentModuleService extends IModuleService { * Learn more about the payment flow in [this guide](https://docs.medusajs.com/experimental/payment/payment-flow/) * * @param {string} id - The payment session's ID. - * @param {Record} context - Context data to pass to the associated payment provider. + * @param {AuthorizePaymentSessionDTO} data - The attributes to authorize in a payment session. * @param {Context} sharedContext - A context used to share resources, such as transaction manager, between the application and the module. * @returns {Promise} The created payment. * * @example - * const payment = - * await paymentModuleService.authorizePaymentSession( - * "payses_123", - * {} - * ) + * await paymentModuleService.authorizePaymentSession("payses_123", { provider_token: "" }) */ authorizePaymentSession( id: string, - context: Record, + data?: AuthorizePaymentSessionDTO, sharedContext?: Context ): Promise @@ -622,35 +619,34 @@ export interface IPaymentModuleService extends IModuleService { * * Learn more about the payment flow in [this guide](https://docs.medusajs.com/experimental/payment/payment-flow/) * + * @param {string} paymentId - The ID of the payment to create the capture for. * @param {CreateCaptureDTO} data - The payment capture to be created. * @param {Context} sharedContext - A context used to share resources, such as transaction manager, between the application and the module. * @returns {Promise} The payment's details. * * @example - * const payment = await paymentModuleService.capturePayment({ - * payment_id: "pay_123", - * }) + * const payment = await paymentModuleService.capturePayment("pay_123") */ capturePayment( - data: CreateCaptureDTO, + paymentId: string, + data?: CreateCaptureDTO, sharedContext?: Context ): Promise /** * This method refunds a payment using its associated payment provider. An amount can only be refunded if it has been captured first. * + * @param {string} paymentId - The ID of the payment to create the refund for. * @param {CreateRefundDTO} data - The refund to be created. * @param {Context} sharedContext - A context used to share resources, such as transaction manager, between the application and the module. * @returns {Promise} The payment's details. * * @example - * const payment = await paymentModuleService.refundPayment({ - * payment_id: "pay_123", - * amount: 300, - * }) + * const payment = await paymentModuleService.refundPayment("pay_123", { amount: 300 }) */ refundPayment( - data: CreateRefundDTO, + paymentId: string, + data?: CreateRefundDTO, sharedContext?: Context ): Promise diff --git a/packages/core/utils/src/payment/abstract-payment-provider.ts b/packages/core/utils/src/payment/abstract-payment-provider.ts index 31b43d9998730..dadd0a4a7e32c 100644 --- a/packages/core/utils/src/payment/abstract-payment-provider.ts +++ b/packages/core/utils/src/payment/abstract-payment-provider.ts @@ -1,37 +1,37 @@ import { + AuthorizePaymentProviderSession, + BigNumberInput, CreatePaymentProviderSession, IPaymentProvider, MedusaContainer, PaymentProviderError, PaymentProviderSessionResponse, - PaymentSessionStatus, ProviderWebhookPayload, UpdatePaymentProviderSession, - WebhookActionResult } from "@medusajs/types" /** * ## Overview * * A payment provider is used to handle and process payments, such as authorizing, capturing, and refund payments. - * + * * :::note - * + * * This guide is a work in progress. - * + * * ::: - * + * * --- - * + * * ## How to Create a Payment Provider - * + * * A payment provider is a TypeScript or JavaScript class that extends the `AbstractPaymentProvider` class imported from `@medusajsa/utils`. - * + * * You can create the payment provider in a module or plugin, then pass that module/plugin in the Payment Module's `providers` option. You can also pass the path to the file * that defines the provider if it's created in the Medusa application's codebase. - * + * * For example: - * + * * ```ts * abstract class MyPayment extends AbstractPaymentProvider { * // ... @@ -39,23 +39,23 @@ import { * ``` * * --- - * + * * ## Configuration Type Parameter - * + * * The `AbstractPaymentProvider` class accepts an optional type parameter that defines the type of configuration that your payment provider expects. - * + * * For example: - * + * * ```ts * interface MyConfigurations { * apiKey: string * } - * + * * abstract class MyPayment extends AbstractPaymentProvider { * // ... * } * ``` - * + * * --- * * ## Identifier Property @@ -122,7 +122,7 @@ import { * * // used in other methods * async retrievePayment( - * paymentSessionData: Record + * paymentSessionData: PaymentProviderSessionResponse["data"] * ): Promise< * PaymentProviderError | * PaymentProviderSessionResponse["session_data"] @@ -215,52 +215,42 @@ export abstract class AbstractPaymentProvider> } abstract capturePayment( - paymentSessionData: Record - ): Promise + paymentSessionData: PaymentProviderSessionResponse["data"], + captureAmount?: BigNumberInput + ): Promise abstract authorizePayment( - paymentSessionData: Record, - context: Record - ): Promise< - | PaymentProviderError - | { - status: PaymentSessionStatus - data: PaymentProviderSessionResponse["data"] - } - > + data: AuthorizePaymentProviderSession + ): Promise abstract cancelPayment( - paymentSessionData: Record - ): Promise + paymentSessionData: PaymentProviderSessionResponse["data"] + ): Promise abstract initiatePayment( - context: CreatePaymentProviderSession + data: CreatePaymentProviderSession ): Promise abstract deletePayment( - paymentSessionData: Record - ): Promise - - abstract getPaymentStatus( - paymentSessionData: Record - ): Promise + paymentSessionData: PaymentProviderSessionResponse["data"] + ): Promise abstract refundPayment( - paymentSessionData: Record, - refundAmount: number - ): Promise + paymentSessionData: PaymentProviderSessionResponse["data"], + refundAmount?: BigNumberInput + ): Promise abstract retrievePayment( - paymentSessionData: Record - ): Promise + paymentSessionData: PaymentProviderSessionResponse["data"] + ): Promise abstract updatePayment( - context: UpdatePaymentProviderSession + data: UpdatePaymentProviderSession ): Promise abstract getWebhookActionAndData( data: ProviderWebhookPayload["payload"] - ): Promise + ): Promise } /** diff --git a/packages/core/utils/src/payment/payment-collection.ts b/packages/core/utils/src/payment/payment-collection.ts index 600445705f50d..ff85d306ca82c 100644 --- a/packages/core/utils/src/payment/payment-collection.ts +++ b/packages/core/utils/src/payment/payment-collection.ts @@ -5,23 +5,31 @@ */ export enum PaymentCollectionStatus { /** - * The payment collection isn't paid. + * The payment collection is pending. */ - NOT_PAID = "not_paid", + PENDING = "pending", /** - * The payment collection is awaiting payment. + * The payment collection is paid. */ - AWAITING = "awaiting", + PAID = "paid", + /** + * The payment collection is partially paid. + */ + PARTIALLY_PAID = "partially_paid", /** * The payment collection is authorized. */ AUTHORIZED = "authorized", /** - * Some of the payments in the payment collection are authorized. + * The payment collection is partially authorized. */ PARTIALLY_AUTHORIZED = "partially_authorized", /** - * The payment collection is canceled. + * The payment collection is refunded. + */ + REFUNDED = "refunded", + /** + * The payment collection is refunded. */ - CANCELED = "canceled", + PARTIALLY_REFUNDED = "partially_refunded", } diff --git a/packages/core/utils/src/payment/payment-session.ts b/packages/core/utils/src/payment/payment-session.ts index 65e127daa0680..13a47d31d6b9e 100644 --- a/packages/core/utils/src/payment/payment-session.ts +++ b/packages/core/utils/src/payment/payment-session.ts @@ -12,6 +12,18 @@ export enum PaymentSessionStatus { * The payment is captured. */ CAPTURED = "captured", + /** + * The payment is partially captured. + */ + PARTIALLY_CAPTURED = "partially_captured", + /** + * The payment is refunded. + */ + REFUNDED = "refunded", + /** + * The payment is refunded. + */ + PARTIALLY_REFUNDED = "partially_refunded", /** * The payment is pending. */ @@ -20,12 +32,12 @@ export enum PaymentSessionStatus { * The payment requires an action. */ REQUIRES_MORE = "requires_more", - /** - * An error occurred while processing the payment. - */ - ERROR = "error", /** * The payment is canceled. */ CANCELED = "canceled", + /** + * The payment is being processing. + */ + PROCESSING = "processing", } diff --git a/packages/medusa/src/api/store/orders/[id]/route.ts b/packages/medusa/src/api/store/orders/[id]/route.ts index 217e87cbea7f9..a4eab69080ad6 100644 --- a/packages/medusa/src/api/store/orders/[id]/route.ts +++ b/packages/medusa/src/api/store/orders/[id]/route.ts @@ -1,5 +1,5 @@ +import { getOrderDetailWorkflow } from "@medusajs/core-flows" import { MedusaRequest, MedusaResponse } from "../../../../types/routing" -import { refetchOrder } from "../helpers" import { StoreGetOrdersParamsType } from "../validators" // TODO: Do we want to apply some sort of authentication here? My suggestion is that we do @@ -7,11 +7,12 @@ export const GET = async ( req: MedusaRequest, res: MedusaResponse ) => { - const order = await refetchOrder( - req.params.id, - req.scope, - req.remoteQueryConfig.fields - ) + const { result } = await getOrderDetailWorkflow(req.scope).run({ + input: { + fields: req.remoteQueryConfig.fields, + order_id: req.params.id, + }, + }) - res.json({ order }) + res.json({ order: result }) } 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 e11e4552679b9..ffc56ff0db179 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,7 +11,7 @@ export const POST = async ( res: MedusaResponse ) => { const collectionId = req.params.id - const { context = {}, data, provider_id } = req.body + const { context = {}, data, provider_id, amount } = req.body // If the customer is logged in, we auto-assign them to the payment collection if (req.auth_context?.actor_id) { @@ -21,7 +21,8 @@ export const POST = async ( } const workflowInput = { payment_collection_id: collectionId, - provider_id: provider_id, + provider_id, + amount, data, context, } diff --git a/packages/medusa/src/api/store/payment-collections/validators.ts b/packages/medusa/src/api/store/payment-collections/validators.ts index d7b2514bc1814..559c54c09ad46 100644 --- a/packages/medusa/src/api/store/payment-collections/validators.ts +++ b/packages/medusa/src/api/store/payment-collections/validators.ts @@ -14,6 +14,7 @@ export const StoreCreatePaymentSession = z provider_id: z.string(), context: z.record(z.unknown()).optional(), data: z.record(z.unknown()).optional(), + amount: z.number().optional(), }) .strict() diff --git a/packages/modules/payment/src/migrations/Migration20240902113510.ts b/packages/modules/payment/src/migrations/Migration20240902113510.ts new file mode 100644 index 0000000000000..3509c56b49f61 --- /dev/null +++ b/packages/modules/payment/src/migrations/Migration20240902113510.ts @@ -0,0 +1,22 @@ +import { Migration } from "@mikro-orm/migrations" + +export class Migration20240902113510 extends Migration { + async up(): Promise { + this.addSql( + 'alter table if exists "capture" add column if not exists "data" jsonb not null;' + ) + this.addSql( + 'alter table if exists "refund" add column if not exists "data" jsonb not null;' + ) + this.addSql( + 'alter table if exists "payment_collection" drop constraint if exists "payment_collection_status_check";' + ) + this.addSql( + 'alter table if exists "payment_session" drop constraint if exists "payment_session_status_check";' + ) + } + + async down(): Promise { + // TODO + } +} diff --git a/packages/modules/payment/src/models/capture.ts b/packages/modules/payment/src/models/capture.ts index 28f3f2b8b89d9..f98361dae391e 100644 --- a/packages/modules/payment/src/models/capture.ts +++ b/packages/modules/payment/src/models/capture.ts @@ -62,6 +62,12 @@ export default class Capture { @Property({ columnType: "text", nullable: true }) created_by: string | null = null + @Property({ columnType: "jsonb", nullable: true }) + data: Record | null = null + + @Property({ columnType: "jsonb", nullable: true }) + metadata: Record | null = null + @BeforeCreate() onCreate() { this.id = generateEntityId(this.id, "capt") diff --git a/packages/modules/payment/src/models/payment-collection.ts b/packages/modules/payment/src/models/payment-collection.ts index fefa36eb74328..3a06b3f240d4b 100644 --- a/packages/modules/payment/src/models/payment-collection.ts +++ b/packages/modules/payment/src/models/payment-collection.ts @@ -95,9 +95,9 @@ export default class PaymentCollection { @Enum({ items: () => PaymentCollectionStatus, - default: PaymentCollectionStatus.NOT_PAID, + default: PaymentCollectionStatus.PENDING, }) - status: PaymentCollectionStatus = PaymentCollectionStatus.NOT_PAID + status: PaymentCollectionStatus = PaymentCollectionStatus.PENDING @ManyToMany(() => PaymentProvider) payment_providers = new Collection>(this) diff --git a/packages/modules/payment/src/models/refund.ts b/packages/modules/payment/src/models/refund.ts index f00d2009fa5d1..2c63c5df7553e 100644 --- a/packages/modules/payment/src/models/refund.ts +++ b/packages/modules/payment/src/models/refund.ts @@ -57,6 +57,9 @@ export default class Refund { @Property({ columnType: "text", nullable: true }) created_by: string | null = null + @Property({ columnType: "jsonb", nullable: true }) + data: Record | null = null + @Property({ columnType: "jsonb", nullable: true }) metadata: Record | null = null diff --git a/packages/modules/payment/src/providers/system.ts b/packages/modules/payment/src/providers/system.ts index 88c61e5675490..28a61cf8124c0 100644 --- a/packages/modules/payment/src/providers/system.ts +++ b/packages/modules/payment/src/providers/system.ts @@ -1,82 +1,192 @@ import { + AuthorizePaymentProviderSession, + BigNumberInput, + BigNumberValue, CreatePaymentProviderSession, + PaymentProviderContext, PaymentProviderError, PaymentProviderSessionResponse, + PaymentSessionStatus, ProviderWebhookPayload, - WebhookActionResult, + UpdatePaymentProviderSession, } from "@medusajs/types" -import { - AbstractPaymentProvider, - PaymentActions, - PaymentSessionStatus, -} from "@medusajs/utils" +import { AbstractPaymentProvider, BigNumber, MathBN } from "@medusajs/utils" + +type SystemProviderPaymentSession = { + status: PaymentSessionStatus + amount: BigNumberValue + capturedAmount: BigNumberValue + refundedAmount: BigNumberValue + currency_code: string + context: PaymentProviderContext +} export class SystemProviderService extends AbstractPaymentProvider { static identifier = "system" static PROVIDER = "system" - async getStatus(_): Promise { - return "authorized" - } - - async getPaymentData(_): Promise> { - return {} - } - async initiatePayment( - context: CreatePaymentProviderSession + data: CreatePaymentProviderSession ): Promise { - return { data: {} } + const paymentSessionData: SystemProviderPaymentSession = { + status: "captured", + amount: new BigNumber(data.amount).valueOf(), + capturedAmount: new BigNumber(data.amount).valueOf(), + refundedAmount: 0, + currency_code: data.currency_code, + context: data.context, + } + return { + status: paymentSessionData.status, + capturedAmount: paymentSessionData.capturedAmount, + refundedAmount: paymentSessionData.refundedAmount, + context: paymentSessionData.context, + data: paymentSessionData, + } } - async getPaymentStatus( - paymentSessionData: Record - ): Promise { - throw new Error("Method not implemented.") + async retrievePayment( + paymentSessionData: SystemProviderPaymentSession + ): Promise { + return { + status: paymentSessionData.status, + capturedAmount: paymentSessionData.capturedAmount, + refundedAmount: paymentSessionData.refundedAmount, + context: paymentSessionData.context, + data: paymentSessionData, + } } - async retrievePayment( - paymentSessionData: Record - ): Promise | PaymentProviderError> { - return {} + async authorizePayment({ + data, + }: AuthorizePaymentProviderSession): Promise< + PaymentProviderError | PaymentProviderSessionResponse + > { + const paymentSessionData = data as SystemProviderPaymentSession + return { + status: paymentSessionData.status, + capturedAmount: paymentSessionData.capturedAmount, + refundedAmount: paymentSessionData.refundedAmount, + context: paymentSessionData.context, + data: paymentSessionData, + } } - async authorizePayment(_): Promise< - | PaymentProviderError - | { - status: PaymentSessionStatus - data: PaymentProviderSessionResponse["data"] - } + async updatePayment({ + data, + context, + amount, + }: UpdatePaymentProviderSession): Promise< + PaymentProviderError | PaymentProviderSessionResponse > { - return { data: {}, status: PaymentSessionStatus.AUTHORIZED } + const paymentSessionData = data as SystemProviderPaymentSession + if (amount && amount < paymentSessionData.capturedAmount) { + return { + error: + "You cannot update payment amount less than the captured amount, please use refund", + code: "", + detail: "", + } + } + const toUpdate: Partial = {} + if (amount) { + toUpdate.amount = new BigNumber(amount).valueOf() + toUpdate.capturedAmount = toUpdate.amount + } + if (context) toUpdate.context = context + const paymentSessionData_ = { ...paymentSessionData, ...toUpdate } + return { + status: paymentSessionData_.status, + capturedAmount: paymentSessionData_.capturedAmount, + refundedAmount: paymentSessionData_.refundedAmount, + context: paymentSessionData_.context, + data: paymentSessionData_, + } } - async updatePayment( - _ + async deletePayment( + paymentSessionData: SystemProviderPaymentSession + ): Promise {} + + async capturePayment( + paymentSessionData: SystemProviderPaymentSession, + captureAmount?: BigNumberInput ): Promise { - return { data: {} } as PaymentProviderSessionResponse + // already captured when payment was created + return { + error: + "You cannot capture more than the authorized amount subtracted by what is already captured.", + code: "", + detail: "", + } } - async deletePayment(_): Promise> { - return {} - } + async refundPayment( + paymentSessionData: SystemProviderPaymentSession, + amount?: BigNumberInput + ): Promise { + const refundableAmount = MathBN.sub( + paymentSessionData.capturedAmount, + paymentSessionData.refundedAmount + ) + const refundAmount = amount ? new BigNumber(amount) : refundableAmount - async capturePayment(_): Promise> { - return {} - } + if ( + MathBN.eq(refundableAmount, 0) || + MathBN.gt(refundAmount, refundableAmount) + ) { + return { + error: "You cannot refund more than what is captured on the payment.", + code: "", + detail: "", + } + } + + if (MathBN.lte(refundAmount, 0)) { + return { + error: "You must refund amount more than 0.", + code: "", + detail: "", + } + } - async refundPayment(_): Promise> { - return {} + const refundedAmount = MathBN.add( + paymentSessionData.refundedAmount, + refundAmount + ) + const isFullyRefunded = MathBN.eq( + paymentSessionData.capturedAmount, + refundedAmount + ) + const status = isFullyRefunded ? "refunded" : "partially_refunded" + + return { + status, + capturedAmount: paymentSessionData.capturedAmount, + refundedAmount, + context: paymentSessionData.context, + data: { + ...paymentSessionData, + status, + refundedAmount, + }, + } } - async cancelPayment(_): Promise> { - return {} + async cancelPayment( + paymentSessionData: SystemProviderPaymentSession + ): Promise { + return { + error: "Captured payment cannot be canceled, please use refund", + code: "", + detail: "", + } } async getWebhookActionAndData( data: ProviderWebhookPayload["payload"] - ): Promise { - return { action: PaymentActions.NOT_SUPPORTED } + ): Promise { + throw new Error("Not supported") } } diff --git a/packages/modules/payment/src/services/payment-module.ts b/packages/modules/payment/src/services/payment-module.ts index eee96f5fa1ed8..a9991adb63c45 100644 --- a/packages/modules/payment/src/services/payment-module.ts +++ b/packages/modules/payment/src/services/payment-module.ts @@ -1,5 +1,5 @@ import { - BigNumberInput, + AuthorizePaymentSessionDTO, CaptureDTO, Context, CreateCaptureDTO, @@ -19,6 +19,7 @@ import { PaymentCollectionUpdatableFields, PaymentDTO, PaymentProviderDTO, + PaymentProviderSessionResponse, PaymentSessionDTO, ProviderWebhookPayload, RefundDTO, @@ -30,13 +31,11 @@ import { import { BigNumber, InjectManager, - InjectTransactionManager, isString, MathBN, MedusaContext, MedusaError, ModulesSdkUtils, - PaymentActions, PaymentCollectionStatus, PaymentSessionStatus, promiseAll, @@ -139,20 +138,18 @@ export default class PaymentModuleService sharedContext ) - return await this.baseRepository_.serialize( + return this.baseRepository_.serialize( Array.isArray(data) ? collections : collections[0], - { - populate: true, - } + { populate: true } ) } - @InjectTransactionManager("baseRepository_") + @InjectManager("baseRepository_") async createPaymentCollections_( data: CreatePaymentCollectionDTO[], @MedusaContext() sharedContext?: Context ): Promise { - return await this.paymentCollectionService_.create(data, sharedContext) + return this.paymentCollectionService_.create(data, sharedContext) } // @ts-ignore @@ -200,11 +197,9 @@ export default class PaymentModuleService sharedContext ) - return await this.baseRepository_.serialize( + return this.baseRepository_.serialize( Array.isArray(data) ? result : result[0], - { - populate: true, - } + { populate: true } ) } @@ -213,7 +208,7 @@ export default class PaymentModuleService data: UpdatePaymentCollectionDTO[], @MedusaContext() sharedContext?: Context ): Promise { - return await this.paymentCollectionService_.update(data, sharedContext) + return this.paymentCollectionService_.update(data, sharedContext) } upsertPaymentCollections( @@ -249,9 +244,9 @@ export default class PaymentModuleService const result = (await promiseAll(operations)).flat() - return await this.baseRepository_.serialize< - PaymentCollectionDTO[] | PaymentCollectionDTO - >(Array.isArray(data) ? result : result[0]) + return this.baseRepository_.serialize( + Array.isArray(data) ? result : result[0] + ) } completePaymentCollections( @@ -282,7 +277,7 @@ export default class PaymentModuleService sharedContext ) - return await this.baseRepository_.serialize( + return this.baseRepository_.serialize( Array.isArray(paymentCollectionId) ? updated : updated[0], { populate: true } ) @@ -291,75 +286,43 @@ export default class PaymentModuleService @InjectManager("baseRepository_") async createPaymentSession( paymentCollectionId: string, - input: CreatePaymentSessionDTO, + data: CreatePaymentSessionDTO, @MedusaContext() sharedContext?: Context ): Promise { - let paymentSession: PaymentSession | undefined + let paymentSession: PaymentSession + let res: PaymentProviderSessionResponse - try { - paymentSession = await this.createPaymentSession_( - paymentCollectionId, - input, - sharedContext - ) - - const providerSessionSession = - await this.paymentProviderService_.createSession(input.provider_id, { - context: { ...input.context, session_id: paymentSession.id }, - amount: input.amount, - currency_code: input.currency_code, - }) - - paymentSession = ( - await this.paymentSessionService_.update( - { - id: paymentSession.id, - data: { ...input.data, ...providerSessionSession }, - }, - sharedContext - ) - )[0] - } catch (error) { - if (paymentSession) { - // In case the session is created, but fails to be updated in Medusa, - // we catch the error and delete the session and rethrow. - await this.paymentProviderService_.deleteSession({ - provider_id: input.provider_id, - data: input.data, - }) - await this.paymentSessionService_.delete( - paymentSession.id, - sharedContext - ) - } - - throw error - } - - return await this.baseRepository_.serialize(paymentSession, { - populate: true, - }) - } - - @InjectTransactionManager("baseRepository_") - async createPaymentSession_( - paymentCollectionId: string, - data: CreatePaymentSessionDTO, - @MedusaContext() sharedContext?: Context - ): Promise { - const paymentSession = await this.paymentSessionService_.create( + paymentSession = await this.paymentSessionService_.create( { payment_collection_id: paymentCollectionId, provider_id: data.provider_id, amount: data.amount, currency_code: data.currency_code, context: data.context, - data: data.data, }, sharedContext ) - return paymentSession + try { + res = await this.paymentProviderService_.createSession(data.provider_id, { + context: { ...data.context, session_id: paymentSession.id }, + amount: data.amount, + currency_code: data.currency_code, + token: data.provider_token, + }) + } catch (err) { + await this.paymentSessionService_.delete(paymentSession.id, sharedContext) + throw err + } + + await this.handleProviderSessionResponse_(res, sharedContext) + paymentSession = await this.paymentSessionService_.retrieve( + paymentSession.id, + {}, + sharedContext + ) + + return this.baseRepository_.serialize(paymentSession, { populate: true }) } @InjectManager("baseRepository_") @@ -367,23 +330,42 @@ export default class PaymentModuleService data: UpdatePaymentSessionDTO, @MedusaContext() sharedContext?: Context ): Promise { + if (!data.amount && !data.context && !data.provider_token) { + throw new MedusaError( + MedusaError.Types.INVALID_ARGUMENT, + `The payment session update requires at least an amount, context, or provider token.` + ) + } + const session = await this.paymentSessionService_.retrieve( data.id, { select: ["id", "data", "provider_id"] }, sharedContext ) - const updated = await this.paymentSessionService_.update( - { - id: session.id, + const context = data.context && { ...data.context, session_id: session.id } + await this.handleProviderSessionResponse_( + await this.paymentProviderService_.updateSession(session.provider_id, { + data: session.data, + context, amount: data.amount, - currency_code: data.currency_code, - data: data.data, - }, + token: data.provider_token, + }), sharedContext ) - return await this.baseRepository_.serialize(updated[0], { populate: true }) + if (data.amount || data.context) { + const toUpdate: any = {} + if (data.amount) toUpdate.amount = data.amount + if (data.context) toUpdate.context = data.context + const updated = await this.paymentSessionService_.update( + { id: session.id, ...toUpdate }, + sharedContext + ) + return this.baseRepository_.serialize(updated[0], { populate: true }) + } else { + return this.retrievePaymentSession(session.id, {}, sharedContext) + } } @InjectManager("baseRepository_") @@ -397,10 +379,10 @@ export default class PaymentModuleService sharedContext ) - await this.paymentProviderService_.deleteSession({ - provider_id: session.provider_id, - data: session.data, - }) + await this.paymentProviderService_.deleteSession( + session.provider_id, + session.data + ) await this.paymentSessionService_.delete(id, sharedContext) } @@ -408,109 +390,85 @@ export default class PaymentModuleService @InjectManager("baseRepository_") async authorizePaymentSession( id: string, - context: Record, + data?: AuthorizePaymentSessionDTO, @MedusaContext() sharedContext?: Context ): Promise { + const paymentSession = await this.paymentSessionService_.retrieve( + id, + { select: ["data", "provider_id", "context"] }, + sharedContext + ) + + await this.handleProviderSessionResponse_( + await this.paymentProviderService_.authorizePayment( + paymentSession.provider_id, + { + data: paymentSession.data, + token: data?.provider_token, + } + ), + sharedContext + ) + + const payment = await this.paymentService_.retrieve( + { payment_session_id: paymentSession.id }, + {}, + sharedContext + ) + + return this.baseRepository_.serialize(payment) + } + + @InjectManager("baseRepository_") + private async authorizePaymentSession_( + id: string, + data?: PaymentProviderSessionResponse["data"], + @MedusaContext() sharedContext?: Context + ): Promise { const session = await this.paymentSessionService_.retrieve( id, { select: [ "id", - "data", - "provider_id", - "amount", "raw_amount", "currency_code", "payment_collection_id", + "provider_id", + "status", ], + relations: ["payment"], }, sharedContext ) - // this method needs to be idempotent - if (session.authorized_at) { - const payment = await this.paymentService_.retrieve( - { session_id: session.id }, - { relations: ["payment_collection"] }, - sharedContext - ) - return await this.baseRepository_.serialize(payment, { populate: true }) - } - - let { data, status } = await this.paymentProviderService_.authorizePayment( - { - provider_id: session.provider_id, - data: session.data, - }, - context - ) - - if ( - status !== PaymentSessionStatus.AUTHORIZED && - status !== PaymentSessionStatus.CAPTURED - ) { + if (session.status === PaymentSessionStatus.CANCELED) { throw new MedusaError( - MedusaError.Types.NOT_ALLOWED, - `Session: ${session.id} is not authorized with the provider.` + MedusaError.Types.INVALID_DATA, + `The payment session: ${session.id} has been canceled.` ) } - let payment - try { - payment = await this.authorizePaymentSession_( - session, - data, - status as PaymentSessionStatus, + if (session.payment) { + return this.paymentService_.retrieve( + session.payment.id, + {}, sharedContext ) - } catch (error) { - await this.paymentProviderService_.cancelPayment({ - provider_id: session.provider_id, - data, - }) - - throw error - } - - await this.maybeUpdatePaymentCollection_( - session.payment_collection_id, - sharedContext - ) - - return await this.retrievePayment( - payment.id, - { relations: ["payment_collection"] }, - sharedContext - ) - } - - @InjectTransactionManager("baseRepository_") - async authorizePaymentSession_( - session: PaymentSession, - data: Record, - status: PaymentSessionStatus, - @MedusaContext() sharedContext?: Context - ): Promise { - let autoCapture = false - if (status === PaymentSessionStatus.CAPTURED) { - status = PaymentSessionStatus.AUTHORIZED - autoCapture = true } await this.paymentSessionService_.update( { - id: session.id, + id, data, - status, - authorized_at: - status === PaymentSessionStatus.AUTHORIZED ? new Date() : null, + status: PaymentSessionStatus.AUTHORIZED, + authorized_at: new Date(), }, sharedContext ) - const payment = await this.paymentService_.create( + return this.paymentService_.create( { - amount: session.amount, + amount: session.raw_amount, currency_code: session.currency_code, payment_session: session.id, payment_collection_id: session.payment_collection_id, @@ -519,15 +477,6 @@ export default class PaymentModuleService }, sharedContext ) - - if (autoCapture) { - await this.capturePayment( - { payment_id: payment.id, amount: session.amount as BigNumberInput }, - sharedContext - ) - } - - return payment } @InjectManager("baseRepository_") @@ -543,7 +492,7 @@ export default class PaymentModuleService sharedContext ) - return await this.baseRepository_.serialize(session) + return this.baseRepository_.serialize(session) } @InjectManager("baseRepository_") @@ -559,7 +508,7 @@ export default class PaymentModuleService sharedContext ) - return await this.baseRepository_.serialize(sessions) + return this.baseRepository_.serialize(sessions) } @InjectManager("baseRepository_") @@ -570,71 +519,60 @@ export default class PaymentModuleService // NOTE: currently there is no update with the provider but maybe data could be updated const result = await this.paymentService_.update(data, sharedContext) - return await this.baseRepository_.serialize(result[0], { - populate: true, - }) + return this.baseRepository_.serialize(result[0], { populate: true }) } @InjectManager("baseRepository_") async capturePayment( - data: CreateCaptureDTO, - @MedusaContext() sharedContext: Context = {} + paymentId: string, + data?: CreateCaptureDTO, + @MedusaContext() sharedContext?: Context ): Promise { - const [payment, isFullyCaptured] = await this.capturePayment_( - data, + const payment = await this.paymentService_.retrieve( + paymentId, + { select: ["provider_id", "data"] }, sharedContext ) - try { - await this.capturePaymentFromProvider_( - payment, - isFullyCaptured, - sharedContext - ) - } catch (error) { - await super.deleteCaptures(data.payment_id, sharedContext) - throw error + const res = await this.paymentProviderService_.capturePayment( + payment.provider_id, + payment.data!, + data?.amount + ) + res.event = { + ...res.event, + type: "capture", + detail: { + ...res.event?.detail, + captured_by: data?.captured_by, + }, } - await this.maybeUpdatePaymentCollection_( - payment.payment_collection_id, - sharedContext - ) + await this.handleProviderSessionResponse_(res, sharedContext) - return await this.retrievePayment( - payment.id, + return this.retrievePayment( + paymentId, { relations: ["captures"] }, sharedContext ) } - @InjectTransactionManager("baseRepository_") + @InjectManager("baseRepository_") private async capturePayment_( - data: CreateCaptureDTO, - @MedusaContext() sharedContext: Context = {} - ): Promise<[Payment, boolean]> { + paymentId: string, + data?: PaymentProviderSessionResponse["data"], + { amount, captured_by }: CreateCaptureDTO = {}, + @MedusaContext() sharedContext?: Context + ): Promise { const payment = await this.paymentService_.retrieve( - data.payment_id, + paymentId, { - select: [ - "id", - "data", - "provider_id", - "payment_collection_id", - "amount", - "raw_amount", - "canceled_at", - ], - relations: ["captures.raw_amount"], + select: ["id", "raw_amount", "canceled_at", "captured_at"], + relations: ["captures"], }, sharedContext ) - // If no custom amount is passed, we assume the full amount needs to be captured - if (!data.amount) { - data.amount = payment.amount as number - } - if (payment.canceled_at) { throw new MedusaError( MedusaError.Types.INVALID_DATA, @@ -643,28 +581,33 @@ export default class PaymentModuleService } if (payment.captured_at) { - return [ - (await this.retrievePayment( - data.payment_id, - { relations: ["captures"] }, - sharedContext - )) as unknown as Payment, - true, - ] + throw new MedusaError( + MedusaError.Types.INVALID_DATA, + `The payment: ${payment.id} has been captured.` + ) } - const capturedAmount = payment.captures.reduce((captureAmount, next) => { - return MathBN.add(captureAmount, next.raw_amount) - }, MathBN.convert(0)) + const capturedAmount = payment.captures.reduce( + (amount, { raw_amount }) => MathBN.add(amount, raw_amount), + MathBN.convert(0) + ) + const remainingToCapture = MathBN.sub(payment.raw_amount, capturedAmount) + const newCaptureAmount = amount ? new BigNumber(amount) : remainingToCapture - const authorizedAmount = new BigNumber(payment.raw_amount) - const newCaptureAmount = new BigNumber(data.amount) - const remainingToCapture = MathBN.sub(authorizedAmount, capturedAmount) + if ( + MathBN.eq(remainingToCapture, 0) || + MathBN.gt(newCaptureAmount, remainingToCapture) + ) { + throw new MedusaError( + MedusaError.Types.INVALID_DATA, + `You cannot capture more than the authorized amount subtracted by what is already captured.` + ) + } - if (MathBN.gt(newCaptureAmount, remainingToCapture)) { + if (MathBN.lte(newCaptureAmount, 0)) { throw new MedusaError( MedusaError.Types.INVALID_DATA, - `You cannot capture more than the authorized amount substracted by what is already captured.` + `You must capture amount more than 0.` ) } @@ -672,137 +615,115 @@ export default class PaymentModuleService const totalCaptured = MathBN.convert( MathBN.add(capturedAmount, newCaptureAmount) ) - const isFullyCaptured = MathBN.gte(totalCaptured, authorizedAmount) + const isFullyCaptured = MathBN.gte(totalCaptured, payment.raw_amount) - await this.captureService_.create( + const capture = await this.captureService_.create( { - payment: data.payment_id, - amount: data.amount, - captured_by: data.captured_by, + payment: paymentId, + amount: newCaptureAmount, + created_by: captured_by, + data, }, sharedContext ) - return [payment, isFullyCaptured] - } - @InjectManager("baseRepository_") - private async capturePaymentFromProvider_( - payment: Payment, - isFullyCaptured: boolean, - @MedusaContext() sharedContext: Context = {} - ) { - const paymentData = await this.paymentProviderService_.capturePayment({ - data: payment.data!, - provider_id: payment.provider_id, - }) - await this.paymentService_.update( - { - id: payment.id, - data: paymentData, - captured_at: isFullyCaptured ? new Date() : undefined, - }, + { id: paymentId, captured_at: isFullyCaptured ? new Date() : null, data }, sharedContext ) - return payment + return capture } @InjectManager("baseRepository_") async refundPayment( - data: CreateRefundDTO, - @MedusaContext() sharedContext: Context = {} + paymentId: string, + data?: CreateRefundDTO, + @MedusaContext() sharedContext?: Context ): Promise { - const payment = await this.refundPayment_(data, sharedContext) + const payment = await this.paymentService_.retrieve( + paymentId, + { select: ["provider_id", "data"] }, + sharedContext + ) - try { - await this.refundPaymentFromProvider_(payment, sharedContext) - } catch (error) { - await super.deleteRefunds(data.payment_id, sharedContext) - throw error + const res = await this.paymentProviderService_.refundPayment( + payment.provider_id, + payment.data!, + data?.amount + ) + res.event = { + ...res.event, + type: "refund", + detail: { + ...res.event?.detail, + refunded_by: data?.refunded_by, + }, } - await this.maybeUpdatePaymentCollection_( - payment.payment_collection_id, - sharedContext - ) + await this.handleProviderSessionResponse_(res, sharedContext) - return await this.retrievePayment( - payment.id, + return this.retrievePayment( + paymentId, { relations: ["refunds"] }, sharedContext ) } - @InjectTransactionManager("baseRepository_") + @InjectManager("baseRepository_") private async refundPayment_( - data: CreateRefundDTO, - @MedusaContext() sharedContext: Context = {} - ): Promise { + paymentId: string, + data?: PaymentProviderSessionResponse["data"], + { amount, refunded_by }: CreateRefundDTO = {}, + @MedusaContext() sharedContext?: Context + ): Promise { const payment = await this.paymentService_.retrieve( - data.payment_id, - { - select: [ - "id", - "data", - "provider_id", - "payment_collection_id", - "amount", - "raw_amount", - ], - relations: ["captures.raw_amount"], - }, + paymentId, + { relations: ["captures", "refunds"] }, sharedContext ) - if (!data.amount) { - data.amount = payment.amount as BigNumberInput - } - - const capturedAmount = payment.captures.reduce((captureAmount, next) => { - const amountAsBigNumber = new BigNumber(next.raw_amount) - return MathBN.add(captureAmount, amountAsBigNumber) - }, MathBN.convert(0)) - const refundAmount = new BigNumber(data.amount) + const capturedAmount = payment.captures.reduce( + (amount, { raw_amount }) => MathBN.add(amount, raw_amount), + MathBN.convert(0) + ) + const refundedAmount = payment.refunds.reduce( + (amount, { raw_amount }) => MathBN.add(amount, raw_amount), + MathBN.convert(0) + ) + const refundableAmount = MathBN.sub(capturedAmount, refundedAmount) + const refundAmount = amount ? new BigNumber(amount) : refundableAmount - if (MathBN.lt(capturedAmount, refundAmount)) { + if ( + MathBN.eq(refundableAmount, 0) || + MathBN.gt(refundAmount, refundableAmount) + ) { throw new MedusaError( MedusaError.Types.INVALID_DATA, `You cannot refund more than what is captured on the payment.` ) } - await this.refundService_.create( - { - payment: data.payment_id, - amount: data.amount, - created_by: data.created_by, - }, - sharedContext - ) - - return payment - } + if (MathBN.lte(refundAmount, 0)) { + throw new MedusaError( + MedusaError.Types.INVALID_DATA, + `You must refund amount more than 0.` + ) + } - @InjectManager("baseRepository_") - private async refundPaymentFromProvider_( - payment: Payment, - @MedusaContext() sharedContext: Context = {} - ) { - const paymentData = await this.paymentProviderService_.refundPayment( + const refund = await this.refundService_.create( { - data: payment.data!, - provider_id: payment.provider_id, + payment: paymentId, + amount: refundAmount, + data, + created_by: refunded_by, }, - payment.raw_amount - ) - - await this.paymentService_.update( - { id: payment.id, data: paymentData }, sharedContext ) - return payment + await this.paymentService_.update({ id: paymentId, data }, sharedContext) + + return refund } @InjectManager("baseRepository_") @@ -816,25 +737,15 @@ export default class PaymentModuleService sharedContext ) - // TODO: revisit when totals are implemented - // if (payment.captured_amount !== 0) { - // throw new MedusaError( - // MedusaError.Types.INVALID_DATA, - // `Cannot cancel a payment: ${payment.id} that has been captured.` - // ) - // } - - await this.paymentProviderService_.cancelPayment({ - data: payment.data!, - provider_id: payment.provider_id, - }) - - await this.paymentService_.update( - { id: paymentId, canceled_at: new Date() }, + await this.handleProviderSessionResponse_( + await this.paymentProviderService_.cancelPayment( + payment.provider_id, + payment.data! + ), sharedContext ) - return await this.retrievePayment(payment.id, {}, sharedContext) + return this.retrievePayment(payment.id, {}, sharedContext) } @InjectManager("baseRepository_") @@ -842,41 +753,13 @@ export default class PaymentModuleService eventData: ProviderWebhookPayload, @MedusaContext() sharedContext?: Context ): Promise { - const providerId = `pp_${eventData.provider}` - - const event = await this.paymentProviderService_.getWebhookActionAndData( - providerId, - eventData.payload + await this.handleProviderSessionResponse_( + await this.paymentProviderService_.getWebhookActionAndData( + `pp_${eventData.provider}`, + eventData.payload + ), + sharedContext ) - - if (event.action === PaymentActions.NOT_SUPPORTED) { - return - } - - switch (event.action) { - case PaymentActions.SUCCESSFUL: { - const [payment] = await this.listPayments( - { - payment_session_id: event.data.session_id, - }, - {}, - sharedContext - ) - if (payment && !payment.captured_at) { - await this.capturePayment( - { payment_id: payment.id, amount: event.data.amount }, - sharedContext - ) - } - break - } - case PaymentActions.AUTHORIZED: - await this.authorizePaymentSession( - event.data.session_id, - {}, - sharedContext - ) - } } @InjectManager("baseRepository_") @@ -891,12 +774,7 @@ export default class PaymentModuleService sharedContext ) - return await this.baseRepository_.serialize( - providers, - { - populate: true, - } - ) + return this.baseRepository_.serialize(providers, { populate: true }) } @InjectManager("baseRepository_") @@ -912,13 +790,79 @@ export default class PaymentModuleService ) return [ - await this.baseRepository_.serialize(providers, { - populate: true, - }), + await this.baseRepository_.serialize(providers, { populate: true }), count, ] } + @InjectManager("baseRepository_") + private async maybeUpdatePaymentSession_( + paymentSessionId: string, + data?: PaymentProviderSessionResponse["data"], + sharedContext?: Context + ) { + const paymentSession = await this.paymentSessionService_.retrieve( + paymentSessionId, + { + select: ["raw_amount", "status"], + relations: ["payment.captures", "payment.refunds"], + }, + sharedContext + ) + + if (paymentSession.status === PaymentSessionStatus.CANCELED) return + + if ( + paymentSession.status === PaymentSessionStatus.PENDING || + paymentSession.status === PaymentSessionStatus.REQUIRES_MORE + ) { + if (data) { + await this.paymentSessionService_.update( + { id: paymentSession.id, data }, + sharedContext + ) + } + } else { + let status: PaymentSessionStatus = paymentSession.status + + if (paymentSession.payment!.canceled_at) { + status = PaymentSessionStatus.CANCELED + } else { + const capturedAmount = paymentSession.payment!.captures.reduce( + (amount, { raw_amount }) => MathBN.add(amount, raw_amount), + MathBN.convert(0) + ) + const refundedAmount = paymentSession.payment!.refunds.reduce( + (amount, { raw_amount }) => MathBN.add(amount, raw_amount), + MathBN.convert(0) + ) + + if (MathBN.gt(capturedAmount, 0)) { + if (MathBN.gt(refundedAmount, 0)) { + if (MathBN.eq(capturedAmount, refundedAmount)) { + status = PaymentSessionStatus.REFUNDED + } else { + status = PaymentSessionStatus.PARTIALLY_REFUNDED + } + } else if ( + MathBN.eq(capturedAmount, paymentSession.payment!.raw_amount) + ) { + status = PaymentSessionStatus.CAPTURED + } else { + status = PaymentSessionStatus.PARTIALLY_CAPTURED + } + } + } + + if (data || status !== paymentSession.status) { + await this.paymentSessionService_.update( + { id: paymentSession.id, data, status }, + sharedContext + ) + } + } + } + @InjectManager("baseRepository_") private async maybeUpdatePaymentCollection_( paymentCollectionId: string, @@ -927,65 +871,197 @@ export default class PaymentModuleService const paymentCollection = await this.paymentCollectionService_.retrieve( paymentCollectionId, { - select: ["amount", "raw_amount", "status"], - relations: [ - "payment_sessions.amount", - "payment_sessions.raw_amount", - "payments.captures.amount", - "payments.captures.raw_amount", - "payments.refunds.amount", - "payments.refunds.raw_amount", - ], + select: ["raw_amount", "status"], + relations: ["payments.captures", "payments.refunds"], }, sharedContext ) + const payments = paymentCollection.payments.filter( + (payment) => !payment.canceled_at + ) + let status: PaymentCollectionStatus = paymentCollection.status - const paymentSessions = paymentCollection.payment_sessions - const captures = paymentCollection.payments - .map((pay) => [...pay.captures]) + const authorizedAmount = payments.reduce( + (amount, { raw_amount }) => MathBN.add(amount, raw_amount), + MathBN.convert(0) + ) + const capturedAmount = payments + .map(({ captures }) => captures.slice()) .flat() - const refunds = paymentCollection.payments - .map((pay) => [...pay.refunds]) + .reduce( + (amount, { raw_amount }) => MathBN.add(amount, raw_amount), + MathBN.convert(0) + ) + const refundedAmount = payments + .map(({ refunds }) => refunds.slice()) .flat() + .reduce( + (amount, { raw_amount }) => MathBN.add(amount, raw_amount), + MathBN.convert(0) + ) - let authorizedAmount = MathBN.convert(0) - let capturedAmount = MathBN.convert(0) - let refundedAmount = MathBN.convert(0) - - for (const ps of paymentSessions) { - if (ps.status === PaymentSessionStatus.AUTHORIZED) { - authorizedAmount = MathBN.add(authorizedAmount, ps.amount) + if (MathBN.gt(capturedAmount, 0)) { + if (MathBN.gt(refundedAmount, 0)) { + if (MathBN.eq(capturedAmount, refundedAmount)) { + status = PaymentCollectionStatus.REFUNDED + } else { + status = PaymentCollectionStatus.PARTIALLY_REFUNDED + } + } else if (MathBN.eq(capturedAmount, paymentCollection.raw_amount)) { + status = PaymentCollectionStatus.PAID + } else { + status = PaymentCollectionStatus.PARTIALLY_PAID + } + } else if (MathBN.gt(authorizedAmount, 0)) { + if (MathBN.eq(authorizedAmount, paymentCollection.raw_amount)) { + status = PaymentCollectionStatus.AUTHORIZED + } else { + status = PaymentCollectionStatus.PARTIALLY_AUTHORIZED } } - for (const capture of captures) { - capturedAmount = MathBN.add(capturedAmount, capture.amount) + if (status !== paymentCollection.status) { + await this.paymentCollectionService_.update( + { + id: paymentCollectionId, + status, + authorized_amount: authorizedAmount, + captured_amount: capturedAmount, + refunded_amount: refundedAmount, + }, + sharedContext + ) } + } - for (const refund of refunds) { - refundedAmount = MathBN.add(refundedAmount, refund.amount) - } + @InjectManager("baseRepository_") + private async handleProviderSessionResponse_( + { + status, + capturedAmount, + refundedAmount, + data, + context, + event, + }: PaymentProviderSessionResponse, + @MedusaContext() sharedContext?: Context + ) { + const session_id = context.session_id! - let status = - paymentSessions.length === 0 - ? PaymentCollectionStatus.NOT_PAID - : PaymentCollectionStatus.AWAITING + switch (status) { + case "authorized": { + const payment = await this.authorizePaymentSession_( + session_id, + data, + sharedContext + ) + await this.maybeUpdatePaymentCollection_(payment.payment_collection_id) + break + } - if (MathBN.gt(authorizedAmount, 0)) { - status = MathBN.gte(authorizedAmount, paymentCollection.amount) - ? PaymentCollectionStatus.AUTHORIZED - : PaymentCollectionStatus.PARTIALLY_AUTHORIZED - } + case "captured": + case "partially_captured": + case "refunded": + case "partially_refunded": { + const session = await this.paymentSessionService_.retrieve( + session_id, + { + select: ["payment_collection_id"], + relations: ["payment.captures", "payment.refunds"], + }, + sharedContext + ) - await this.paymentCollectionService_.update( - { - id: paymentCollectionId, - status, - authorized_amount: authorizedAmount, - captured_amount: capturedAmount, - refunded_amount: refundedAmount, - }, - sharedContext - ) + let payment = session.payment + + if (!payment) { + const { id } = await this.authorizePaymentSession_( + session_id, + data, + sharedContext + ) + payment = await this.paymentService_.retrieve( + id, + { + select: ["id", "raw_amount"], + relations: ["captures", "refunds"], + }, + sharedContext + ) + } + + const _capturedAmount = payment.captures.reduce( + (amount, { raw_amount }) => MathBN.add(amount, raw_amount), + MathBN.convert(0) + ) + const _refundedAmount = payment.refunds.reduce( + (amount, { raw_amount }) => MathBN.add(amount, raw_amount), + MathBN.convert(0) + ) + + if (MathBN.gt(capturedAmount, _capturedAmount)) { + await this.capturePayment_( + payment.id, + data, + { + amount: MathBN.sub(capturedAmount, _capturedAmount), + captured_by: event?.detail?.captured_by, + }, + sharedContext + ) + } + if (MathBN.gt(refundedAmount, _refundedAmount)) { + await this.refundPayment_( + payment.id, + data, + { + amount: MathBN.sub(refundedAmount, _refundedAmount), + refunded_by: event?.detail?.refunded_by, + }, + sharedContext + ) + } + await this.maybeUpdatePaymentSession_(session_id, data) + await this.maybeUpdatePaymentCollection_(session.payment_collection_id) + break + } + + case "pending": + case "requires_more": + case "processing": { + await this.paymentSessionService_.update( + { id: session_id, data, status }, + sharedContext + ) + break + } + case "canceled": { + await this.paymentSessionService_.update( + { id: session_id, data, status }, + sharedContext + ) + const session = await this.paymentSessionService_.retrieve( + session_id, + { select: ["payment_collection_id"], relations: ["payment"] }, + sharedContext + ) + if (session.payment) { + await this.paymentService_.update( + { id: session.payment.id, canceled_at: new Date() }, + sharedContext + ) + await this.maybeUpdatePaymentCollection_( + session.payment_collection_id + ) + } + break + } + default: { + throw new MedusaError( + MedusaError.Types.INVALID_DATA, + `Received invalid payment status: ${status}` + ) + } + } } } diff --git a/packages/modules/payment/src/services/payment-provider.ts b/packages/modules/payment/src/services/payment-provider.ts index 1abdfd8629b2e..daafbe1744eb3 100644 --- a/packages/modules/payment/src/services/payment-provider.ts +++ b/packages/modules/payment/src/services/payment-provider.ts @@ -1,4 +1,5 @@ import { + AuthorizePaymentProviderSession, BigNumberInput, Context, CreatePaymentProviderDTO, @@ -8,15 +9,11 @@ import { FindConfig, InternalModuleDeclaration, IPaymentProvider, - PaymentProviderAuthorizeResponse, - PaymentProviderDataInput, PaymentProviderDTO, PaymentProviderError, PaymentProviderSessionResponse, - PaymentSessionStatus, ProviderWebhookPayload, UpdatePaymentProviderSession, - WebhookActionResult, } from "@medusajs/types" import { InjectManager, @@ -100,107 +97,122 @@ export default class PaymentProviderService { } } - async createSession( + async retrieveSession( providerId: string, - sessionInput: CreatePaymentProviderSession - ): Promise { + paymentSessionData: PaymentProviderSessionResponse["data"] + ): Promise { const provider = this.retrieveProvider(providerId) + const res = await provider.retrievePayment(paymentSessionData) + + if (isPaymentProviderError(res)) { + this.throwPaymentProviderError(res) + } + + return res as PaymentProviderSessionResponse + } - const paymentResponse = await provider.initiatePayment(sessionInput) + async createSession( + providerId: string, + data: CreatePaymentProviderSession + ): Promise { + const provider = this.retrieveProvider(providerId) + const res = await provider.initiatePayment(data) - if (isPaymentProviderError(paymentResponse)) { - this.throwPaymentProviderError(paymentResponse) + if (isPaymentProviderError(res)) { + this.throwPaymentProviderError(res) } - return (paymentResponse as PaymentProviderSessionResponse).data + return res as PaymentProviderSessionResponse } async updateSession( providerId: string, - sessionInput: UpdatePaymentProviderSession - ): Promise | undefined> { + data: UpdatePaymentProviderSession + ): Promise { const provider = this.retrieveProvider(providerId) + const res = await provider.updatePayment(data) - const paymentResponse = await provider.updatePayment(sessionInput) - - if (isPaymentProviderError(paymentResponse)) { - this.throwPaymentProviderError(paymentResponse) + if (isPaymentProviderError(res)) { + this.throwPaymentProviderError(res) } - return (paymentResponse as PaymentProviderSessionResponse)?.data + return res as PaymentProviderSessionResponse } - async deleteSession(input: PaymentProviderDataInput): Promise { - const provider = this.retrieveProvider(input.provider_id) + async deleteSession( + providerId: string, + paymentSessionData: PaymentProviderSessionResponse["data"] + ): Promise { + const provider = this.retrieveProvider(providerId) + const res = await provider.deletePayment(paymentSessionData) - const error = await provider.deletePayment(input.data) - if (isPaymentProviderError(error)) { - this.throwPaymentProviderError(error) + if (isPaymentProviderError(res)) { + this.throwPaymentProviderError(res) } } async authorizePayment( - input: PaymentProviderDataInput, - context: Record - ): Promise<{ data: Record; status: PaymentSessionStatus }> { - const provider = this.retrieveProvider(input.provider_id) + providerId: string, + data: AuthorizePaymentProviderSession + ): Promise { + const provider = this.retrieveProvider(providerId) + const res = await provider.authorizePayment(data) - const res = await provider.authorizePayment(input.data, context) if (isPaymentProviderError(res)) { this.throwPaymentProviderError(res) } - const { data, status } = res as PaymentProviderAuthorizeResponse - return { data, status } - } - - async getStatus( - input: PaymentProviderDataInput - ): Promise { - const provider = this.retrieveProvider(input.provider_id) - return await provider.getPaymentStatus(input.data) + return res as PaymentProviderSessionResponse } async capturePayment( - input: PaymentProviderDataInput - ): Promise> { - const provider = this.retrieveProvider(input.provider_id) + providerId: string, + paymentSessionData: PaymentProviderSessionResponse["data"], + captureAmount?: BigNumberInput + ): Promise { + const provider = this.retrieveProvider(providerId) + const res = await provider.capturePayment(paymentSessionData, captureAmount) - const res = await provider.capturePayment(input.data) if (isPaymentProviderError(res)) { this.throwPaymentProviderError(res) } - return res as Record + return res as PaymentProviderSessionResponse } - async cancelPayment(input: PaymentProviderDataInput): Promise { - const provider = this.retrieveProvider(input.provider_id) + async cancelPayment( + providerId: string, + paymentSessionData: PaymentProviderSessionResponse["data"] + ): Promise { + const provider = this.retrieveProvider(providerId) + const res = await provider.cancelPayment(paymentSessionData) - const error = await provider.cancelPayment(input.data) - if (isPaymentProviderError(error)) { - this.throwPaymentProviderError(error) + if (isPaymentProviderError(res)) { + this.throwPaymentProviderError(res) } + + return res as PaymentProviderSessionResponse } async refundPayment( - input: PaymentProviderDataInput, - amount: BigNumberInput - ): Promise> { - const provider = this.retrieveProvider(input.provider_id) + providerId: string, + paymentSessionData: PaymentProviderSessionResponse["data"], + refundAmount?: BigNumberInput + ): Promise { + const provider = this.retrieveProvider(providerId) + const res = await provider.refundPayment(paymentSessionData, refundAmount) - const res = await provider.refundPayment(input.data, amount) if (isPaymentProviderError(res)) { this.throwPaymentProviderError(res) } - return res as Record + return res as PaymentProviderSessionResponse } async getWebhookActionAndData( providerId: string, data: ProviderWebhookPayload["payload"] - ): Promise { + ): Promise { const provider = this.retrieveProvider(providerId) return await provider.getWebhookActionAndData(data) 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 a8d0dc5576864..ac17aeba6df12 100644 --- a/packages/modules/providers/payment-stripe/src/core/stripe-base.ts +++ b/packages/modules/providers/payment-stripe/src/core/stripe-base.ts @@ -3,28 +3,22 @@ import { EOL } from "os" import Stripe from "stripe" import { + AuthorizePaymentProviderSession, + BigNumberInput, CreatePaymentProviderSession, MedusaContainer, PaymentProviderError, PaymentProviderSessionResponse, + PaymentSessionStatus, ProviderWebhookPayload, UpdatePaymentProviderSession, - WebhookActionResult, } from "@medusajs/types" import { AbstractPaymentProvider, - MedusaError, - PaymentActions, - PaymentSessionStatus, isDefined, isPaymentProviderError, } from "@medusajs/utils" -import { - ErrorCodes, - ErrorIntentStatus, - PaymentIntentOptions, - StripeOptions, -} from "../types" +import { ErrorCodes, PaymentIntentOptions, StripeOptions } from "../types" import { getAmountFromSmallestUnit, getSmallestUnit, @@ -82,56 +76,50 @@ abstract class StripeBase extends AbstractPaymentProvider { return options } - async getPaymentStatus( - paymentSessionData: Record - ): Promise { - const id = paymentSessionData.id as string - const paymentIntent = await this.stripe_.paymentIntents.retrieve(id) - - switch (paymentIntent.status) { - case "requires_payment_method": - case "requires_confirmation": - case "processing": - return PaymentSessionStatus.PENDING - case "requires_action": - return PaymentSessionStatus.REQUIRES_MORE - case "canceled": - return PaymentSessionStatus.CANCELED - case "requires_capture": - return PaymentSessionStatus.AUTHORIZED - case "succeeded": - return PaymentSessionStatus.CAPTURED - default: - return PaymentSessionStatus.PENDING - } - } - async initiatePayment( - input: CreatePaymentProviderSession + data: CreatePaymentProviderSession ): Promise { const intentRequestData = this.getPaymentIntentOptions() - const { email, extra, session_id, customer } = input.context - const { currency_code, amount } = input - - const description = (extra?.payment_description ?? + const { + email, + session_id, + cart_id, + order_id, + customer, + payment_description, + } = data.context + const { currency_code, amount, token } = data + + const description = (payment_description ?? this.options_?.paymentDescription) as string const intentRequest: Stripe.PaymentIntentCreateParams = { description, amount: getSmallestUnit(amount, currency_code), currency: currency_code, - metadata: { session_id: session_id! }, + payment_method: token, + confirm: !!token, + metadata: { + session_id: session_id as string | null, + cart_id: cart_id as string | null, + order_id: order_id as string | null, + }, capture_method: this.options_.capture ? "automatic" : "manual", + expand: ["latest_charge"], ...intentRequestData, } - if (this.options_?.automaticPaymentMethods) { - intentRequest.automatic_payment_methods = { enabled: true } + const automaticPaymentMethods = this.options_?.automaticPaymentMethods + if (automaticPaymentMethods) { + intentRequest.automatic_payment_methods = + typeof automaticPaymentMethods === "boolean" + ? { enabled: true } + : automaticPaymentMethods } if (customer?.metadata?.stripe_id) { intentRequest.customer = customer.metadata.stripe_id as string - } else { + } else if (this.options_.createCustomer) { let stripeCustomer try { stripeCustomer = await this.stripe_.customers.create({ @@ -147,56 +135,64 @@ abstract class StripeBase extends AbstractPaymentProvider { intentRequest.customer = stripeCustomer.id } - let sessionData try { - sessionData = (await this.stripe_.paymentIntents.create( - intentRequest - )) as unknown as Record + const intent = await this.stripe_.paymentIntents.create(intentRequest) + return { + ...(await this.buildResponse(intent)), + data: intent as unknown as Record, + context: intent.metadata, + } } catch (e) { return this.buildError( - "An error occurred in InitiatePayment during the creation of the stripe payment intent", + "An error occurred in initiatePayment during the creation of the stripe payment intent", e ) } - - return { - 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"] + data: AuthorizePaymentProviderSession + ): Promise { + const { id } = data.data as unknown as Stripe.PaymentIntent + try { + const intent = await this.stripe_.paymentIntents.confirm(id, { + payment_method: data.token, + expand: ["latest_charge"], + }) + return { + ...(await this.buildResponse(intent)), + data: intent as unknown as Record, + context: intent.metadata, } - > { - const status = await this.getPaymentStatus(paymentSessionData) - return { data: paymentSessionData, status } + } catch (e) { + return this.buildError( + "An error occurred in authorizePayment during the confirm of the stripe payment intent", + e + ) + } } async cancelPayment( - paymentSessionData: Record - ): Promise { + paymentSessionData: PaymentProviderSessionResponse["data"] + ): Promise { + const { id } = paymentSessionData as unknown as Stripe.PaymentIntent try { - const id = paymentSessionData.id as string - return (await this.stripe_.paymentIntents.cancel( - id - )) as unknown as PaymentProviderSessionResponse["data"] + const intent = await this.stripe_.paymentIntents.cancel(id, { + expand: ["latest_charge"], + }) + return { + ...(await this.buildResponse(intent)), + data: intent as unknown as Record, + context: intent.metadata, + } } catch (error) { - if (error.payment_intent?.status === ErrorIntentStatus.CANCELED) { - return error.payment_intent + const intent = error.payment_intent as Stripe.PaymentIntent | undefined + if (intent?.status === "canceled") { + return { + ...(await this.buildResponse(intent)), + data: intent as unknown as Record, + context: intent.metadata, + } } return this.buildError("An error occurred in cancelPayment", error) @@ -204,16 +200,33 @@ abstract class StripeBase extends AbstractPaymentProvider { } async capturePayment( - paymentSessionData: Record - ): Promise { - const id = paymentSessionData.id as string + paymentSessionData: PaymentProviderSessionResponse["data"], + captureAmount?: BigNumberInput + ): Promise { + const { id, currency } = + paymentSessionData as unknown as Stripe.PaymentIntent try { - const intent = await this.stripe_.paymentIntents.capture(id) - return intent as unknown as PaymentProviderSessionResponse["data"] + const intent = await this.stripe_.paymentIntents.capture(id, { + amount_to_capture: captureAmount + ? getSmallestUnit(captureAmount, currency) + : undefined, + final_capture: false, + expand: ["latest_charge"], + }) + return { + ...(await this.buildResponse(intent)), + data: intent as unknown as Record, + context: intent.metadata, + } } catch (error) { if (error.code === ErrorCodes.PAYMENT_INTENT_UNEXPECTED_STATE) { - if (error.payment_intent?.status === ErrorIntentStatus.SUCCEEDED) { - return error.payment_intent + const intent = error.payment_intent as Stripe.PaymentIntent | undefined + if (intent?.status === "succeeded") { + return { + ...(await this.buildResponse(intent)), + data: intent as unknown as Record, + context: intent.metadata, + } } } @@ -222,138 +235,96 @@ abstract class StripeBase extends AbstractPaymentProvider { } async deletePayment( - paymentSessionData: Record - ): Promise { - return await this.cancelPayment(paymentSessionData) + paymentSessionData: PaymentProviderSessionResponse["data"] + ): Promise { + const res = await this.cancelPayment(paymentSessionData) + if (isPaymentProviderError(res)) return res } async refundPayment( - paymentSessionData: Record, - refundAmount: number - ): Promise { - const id = paymentSessionData.id as string - + paymentSessionData: PaymentProviderSessionResponse["data"], + refundAmount?: BigNumberInput + ): Promise { + const { id, currency } = + paymentSessionData as unknown as Stripe.PaymentIntent try { - const { currency } = paymentSessionData await this.stripe_.refunds.create({ - amount: getSmallestUnit(refundAmount, currency as string), - payment_intent: id as string, + payment_intent: id, + amount: refundAmount + ? getSmallestUnit(refundAmount, currency) + : undefined, + }) + const intent = await this.stripe_.paymentIntents.retrieve(id, { + expand: ["latest_charge"], }) + return { + ...(await this.buildResponse(intent)), + data: intent as unknown as Record, + context: intent.metadata, + } } catch (e) { return this.buildError("An error occurred in refundPayment", e) } - - return paymentSessionData } async retrievePayment( - paymentSessionData: Record - ): Promise { + paymentSessionData: PaymentProviderSessionResponse["data"] + ): Promise { + const { id } = paymentSessionData as unknown as Stripe.PaymentIntent try { - const id = paymentSessionData.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"] + const intent = await this.stripe_.paymentIntents.retrieve(id, { + expand: ["latest_charge"], + }) + return { + ...(await this.buildResponse(intent)), + data: intent as unknown as Record, + context: intent.metadata, + } } catch (e) { return this.buildError("An error occurred in retrievePayment", e) } } async updatePayment( - input: UpdatePaymentProviderSession + data: UpdatePaymentProviderSession ): Promise { - const { context, data, currency_code, amount } = input - - const amountNumeric = getSmallestUnit(amount, currency_code) - - const stripeId = context.customer?.metadata?.stripe_id - - if (stripeId !== data.customer) { - const result = await this.initiatePayment(input) - if (isPaymentProviderError(result)) { - return this.buildError( - "An error occurred in updatePayment during the initiate of the new payment for the new customer", - result - ) - } - - return result - } else { - if (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) - } - } - } - - async updatePaymentData(sessionId: string, data: Record) { + const { id, currency } = data.data as unknown as Stripe.PaymentIntent + const { context, amount, token } = data try { - // Prevent from updating the amount from here as it should go through - // the updatePayment method to perform the correct logic - if (data.amount) { - throw new MedusaError( - MedusaError.Types.INVALID_DATA, - "Cannot update amount, use updatePayment instead" - ) + const toUpdate: Stripe.PaymentIntentUpdateParams = {} + if (context) { + toUpdate.metadata = { + session_id: context.session_id as string | null, + cart_id: context.cart_id as string | null, + order_id: context.order_id as string | null, + } + } + if (amount) toUpdate.amount = getSmallestUnit(amount, currency) + if (token) toUpdate.payment_method = token + const intent = await this.stripe_.paymentIntents.update(id, { + ...toUpdate, + expand: ["latest_charge"], + }) + return { + ...(await this.buildResponse(intent)), + data: intent as unknown as Record, + context: intent.metadata, } - - return (await this.stripe_.paymentIntents.update(sessionId, { - ...data, - })) as unknown as PaymentProviderSessionResponse["data"] } catch (e) { - return this.buildError("An error occurred in updatePaymentData", e) + return this.buildError("An error occurred in updatePayment", e) } } async getWebhookActionAndData( - webhookData: ProviderWebhookPayload["payload"] - ): Promise { - const event = this.constructWebhookEvent(webhookData) + data: ProviderWebhookPayload["payload"] + ): Promise { + const event = this.constructWebhookEvent(data) const intent = event.data.object as Stripe.PaymentIntent - const { currency } = intent - switch (event.type) { - case "payment_intent.amount_capturable_updated": - return { - action: PaymentActions.AUTHORIZED, - data: { - session_id: intent.metadata.session_id, - amount: getAmountFromSmallestUnit( - intent.amount_capturable, - currency - ), // NOTE: revisit when implementing multicapture - }, - } - case "payment_intent.succeeded": - return { - action: PaymentActions.SUCCESSFUL, - data: { - session_id: intent.metadata.session_id, - amount: getAmountFromSmallestUnit(intent.amount_received, currency), - }, - } - case "payment_intent.payment_failed": - return { - action: PaymentActions.FAILED, - data: { - session_id: intent.metadata.session_id, - amount: getAmountFromSmallestUnit(intent.amount, currency), - }, - } - default: - return { action: PaymentActions.NOT_SUPPORTED } + return { + ...(await this.buildResponse(intent)), + data: intent as unknown as Record, + context: intent.metadata, } } @@ -372,6 +343,7 @@ abstract class StripeBase extends AbstractPaymentProvider { this.config.webhookSecret ) } + protected buildError( message: string, error: Stripe.StripeRawError | PaymentProviderError | Error @@ -386,6 +358,83 @@ abstract class StripeBase extends AbstractPaymentProvider { : error.message ?? "", } } + + protected async buildResponse( + intent: Stripe.PaymentIntent + ): Promise< + Pick< + PaymentProviderSessionResponse, + "status" | "capturedAmount" | "refundedAmount" + > + > { + let status: PaymentSessionStatus + + switch (intent.status) { + case "requires_action": { + status = "requires_more" + break + } + case "processing": { + status = "processing" + break + } + case "requires_capture": { + status = "authorized" + break + } + case "succeeded": { + status = "captured" + break + } + case "canceled": { + status = "canceled" + break + } + default: + status = "pending" + } + + let capturedAmount = 0 + let refundedAmount = 0 + + if (status === "authorized" || status === "captured") { + let charge: Stripe.Charge + if (typeof intent.latest_charge === "string") { + charge = await this.stripe_.charges.retrieve(intent.latest_charge) + } else { + charge = intent.latest_charge as Stripe.Charge + } + const authorizedAmount = getAmountFromSmallestUnit( + charge.amount, + charge.currency + ) + capturedAmount = getAmountFromSmallestUnit( + charge.amount_captured, + charge.currency + ) + refundedAmount = getAmountFromSmallestUnit( + charge.amount_refunded, + charge.currency + ) + if (capturedAmount > 0) { + if (refundedAmount > 0) { + if (capturedAmount === refundedAmount) { + status = "refunded" + } else { + status = "partially_refunded" + } + } else if (capturedAmount < authorizedAmount) { + status = "partially_captured" + } + } + } + + return { + status, + capturedAmount, + refundedAmount, + } + } } export default StripeBase diff --git a/packages/modules/providers/payment-stripe/src/types/index.ts b/packages/modules/providers/payment-stripe/src/types/index.ts index abeb3cb7aaaf1..edcd6330034ba 100644 --- a/packages/modules/providers/payment-stripe/src/types/index.ts +++ b/packages/modules/providers/payment-stripe/src/types/index.ts @@ -1,3 +1,5 @@ +import Stripe from "stripe" + export interface StripeOptions { /** * The API key for the Stripe account @@ -12,13 +14,19 @@ export interface StripeOptions { */ capture?: boolean /** - * set `automatic_payment_methods` on the intent request to `{ enabled: true }` + * set `automatic_payment_methods` on the intent request */ - automaticPaymentMethods?: boolean + automaticPaymentMethods?: + | boolean + | Stripe.PaymentIntentCreateParams.AutomaticPaymentMethods /** * Set a default description on the intent if the context does not provide one */ paymentDescription?: string + /** + * Create a stripe customer if not found + */ + createCustomer?: boolean } export interface PaymentIntentOptions {