From a96bec4c7231c831fba0f827c2e8a7a3e3b3e355 Mon Sep 17 00:00:00 2001 From: shij Date: Thu, 12 Sep 2024 10:54:45 +0800 Subject: [PATCH] feat: payment module optimization --- .gitignore | 1 + .../core/core-flows/src/cart/steps/index.ts | 1 + ...eate-or-update-order-payment-collection.ts | 2 +- .../delete-order-payment-collection.ts | 2 +- .../mark-payment-collection-as-paid.ts | 2 +- .../steps/create-payment-session.ts | 4 +- .../steps/delete-payment-sessions.ts | 1 - .../workflows/create-payment-session.ts | 49 +- .../src/payment/steps/capture-payment.ts | 5 +- .../src/payment/steps/refund-payment.ts | 7 +- packages/core/js-sdk/src/store/index.ts | 16 + .../core/types/src/http/payment/common.ts | 13 +- packages/core/types/src/payment/common.ts | 13 +- packages/core/types/src/payment/mutations.ts | 53 +- packages/core/types/src/payment/provider.ts | 272 +++-- packages/core/types/src/payment/service.ts | 32 +- .../src/payment/abstract-payment-provider.ts | 227 ++-- .../utils/src/payment/payment-collection.ts | 22 +- .../core/utils/src/payment/payment-session.ts | 20 +- .../[id]/payment-sessions/route.ts | 5 +- .../store/payment-collections/validators.ts | 1 + .../src/migrations/Migration20240902113510.ts | 22 + .../modules/payment/src/models/capture.ts | 9 +- .../payment/src/models/payment-collection.ts | 4 +- packages/modules/payment/src/models/refund.ts | 3 + .../modules/payment/src/providers/system.ts | 202 +++- .../payment/src/services/payment-module.ts | 968 ++++++++++-------- .../payment/src/services/payment-provider.ts | 120 ++- .../payment-stripe/src/core/stripe-base.ts | 445 ++++---- .../payment-stripe/src/types/index.ts | 12 +- 30 files changed, 1522 insertions(+), 1011 deletions(-) create mode 100644 packages/modules/payment/src/migrations/Migration20240902113510.ts diff --git a/.gitignore b/.gitignore index e5fde2f33a495..bd7180af65ed6 100644 --- a/.gitignore +++ b/.gitignore @@ -24,3 +24,4 @@ build/** **/stats .favorites.json .vscode +.history \ No newline at end of file diff --git a/packages/core/core-flows/src/cart/steps/index.ts b/packages/core/core-flows/src/cart/steps/index.ts index 3551de79aae54..b028baeb4dcc6 100644 --- a/packages/core/core-flows/src/cart/steps/index.ts +++ b/packages/core/core-flows/src/cart/steps/index.ts @@ -5,6 +5,7 @@ export * from "./create-line-item-adjustments" export * from "./create-line-items" export * from "./create-payment-collection" 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/order/workflows/create-or-update-order-payment-collection.ts b/packages/core/core-flows/src/order/workflows/create-or-update-order-payment-collection.ts index 401046651c358..051324eb01771 100644 --- a/packages/core/core-flows/src/order/workflows/create-or-update-order-payment-collection.ts +++ b/packages/core/core-flows/src/order/workflows/create-or-update-order-payment-collection.ts @@ -50,7 +50,7 @@ export const createOrUpdateOrderPaymentCollectionWorkflow = createWorkflow( variables: { filters: { id: orderPaymentCollectionIds, - status: [PaymentCollectionStatus.NOT_PAID], + status: [PaymentCollectionStatus.PENDING], }, }, list: false, diff --git a/packages/core/core-flows/src/order/workflows/delete-order-payment-collection.ts b/packages/core/core-flows/src/order/workflows/delete-order-payment-collection.ts index 6915010efc73a..6b9fb1397e9d8 100644 --- a/packages/core/core-flows/src/order/workflows/delete-order-payment-collection.ts +++ b/packages/core/core-flows/src/order/workflows/delete-order-payment-collection.ts @@ -13,7 +13,7 @@ import { removeRemoteLinkStep, useRemoteQueryStep } from "../../common" export const throwUnlessStatusIsNotPaid = createStep( "validate-payment-collection", ({ paymentCollection }: { paymentCollection: PaymentCollectionDTO }) => { - if (paymentCollection.status !== PaymentCollectionStatus.NOT_PAID) { + if (paymentCollection.status !== PaymentCollectionStatus.PENDING) { throw new MedusaError( MedusaError.Types.NOT_ALLOWED, `Can only delete payment collections where status is not_paid` diff --git a/packages/core/core-flows/src/order/workflows/mark-payment-collection-as-paid.ts b/packages/core/core-flows/src/order/workflows/mark-payment-collection-as-paid.ts index 26324bb348760..b7ec62b178e9f 100644 --- a/packages/core/core-flows/src/order/workflows/mark-payment-collection-as-paid.ts +++ b/packages/core/core-flows/src/order/workflows/mark-payment-collection-as-paid.ts @@ -19,7 +19,7 @@ import { createPaymentSessionsWorkflow } from "../../payment-collection" export const throwUnlessPaymentCollectionNotPaid = createStep( "validate-existing-payment-collection", ({ paymentCollection }: { paymentCollection: PaymentCollectionDTO }) => { - if (paymentCollection.status !== "not_paid") { + if (paymentCollection.status !== "pending") { throw new MedusaError( MedusaError.Types.NOT_ALLOWED, `Can only mark 'not_paid' payment collection as paid` 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 60f7c0fd4e8f2..3cdc422a0e9b9 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 @@ export interface CreatePaymentSessionStepInput { provider_id: string amount: BigNumberInput currency_code: string + provider_token?: string context?: PaymentProviderContext - data?: Record } export const createPaymentSessionStepId = "create-payment-session" @@ -30,9 +30,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 4ffcb00366be8..7429ed31a54e9 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 @@ -84,7 +84,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 04a4573002248..6325f6b5c210d 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,20 +1,25 @@ -import { PaymentProviderContext, PaymentSessionDTO } from "@medusajs/types" +import { + BigNumberInput, + PaymentProviderContext, + PaymentSessionDTO, +} from "@medusajs/types" +import { MathBN } from "@medusajs/utils" import { WorkflowData, WorkflowResponse, createWorkflow, - parallelize, transform, } from "@medusajs/workflows-sdk" import { useRemoteQueryStep } from "../../common" import { createPaymentSessionStep } from "../steps" -import { deletePaymentSessionsWorkflow } from "./delete-payment-sessions" export interface CreatePaymentSessionsWorkflowInput { payment_collection_id: string provider_id: string + provider_token?: string data?: Record context?: PaymentProviderContext + amount?: BigNumberInput } export const createPaymentSessionsWorkflowId = "create-payment-sessions" @@ -26,7 +31,13 @@ export const createPaymentSessionsWorkflow = createWorkflow( (input: WorkflowData): WorkflowResponse => { 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, }) @@ -34,38 +45,22 @@ 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, } } ) - const deletePaymentSessionInput = transform( - { paymentCollection }, - (data) => { - return { - ids: - data.paymentCollection?.payment_sessions?.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 - const [created] = parallelize( - createPaymentSessionStep(paymentSessionInput), - deletePaymentSessionsWorkflow.runAsStep({ - input: deletePaymentSessionInput, - }) - ) - - return new WorkflowResponse(created) + return new WorkflowResponse(createPaymentSessionStep(paymentSessionInput)) } ) 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 c93c46e95763f..bbe95818302ec 100644 --- a/packages/core/core-flows/src/payment/steps/capture-payment.ts +++ b/packages/core/core-flows/src/payment/steps/capture-payment.ts @@ -19,7 +19,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 989d2187313a9..0abd8cee7f090 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" export type RefundPaymentStepInput = { payment_id: string - created_by?: string + refunded_by?: string amount?: BigNumberInput } @@ -19,7 +19,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 1ba06eb58ac56..c8c3abf14b6b2 100644 --- a/packages/core/js-sdk/src/store/index.ts +++ b/packages/core/js-sdk/src/store/index.ts @@ -309,6 +309,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/http/payment/common.ts b/packages/core/types/src/http/payment/common.ts index bbdf86ba90e6c..99cc1c399f14f 100644 --- a/packages/core/types/src/http/payment/common.ts +++ b/packages/core/types/src/http/payment/common.ts @@ -5,11 +5,13 @@ import { BigNumberValue } from "../../totals" * The payment collection's status. */ export type BasePaymentCollectionStatus = - | "not_paid" - | "awaiting" + | "pending" + | "paid" + | "partially_paid" | "authorized" | "partially_authorized" - | "canceled" + | "refunded" + | "partially_refunded" /** * @@ -18,10 +20,13 @@ export type BasePaymentCollectionStatus = export type BasePaymentSessionStatus = | "authorized" | "captured" + | "partially_captured" + | "refunded" + | "partially_refunded" | "pending" | "requires_more" - | "error" | "canceled" + | "processing" export interface BasePaymentProvider { id: string diff --git a/packages/core/types/src/payment/common.ts b/packages/core/types/src/payment/common.ts index 59f85aa8566d0..4257b59290b2b 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 74fe1e799e3d9..9467ad9e40684 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,26 +202,11 @@ export interface CreateRefundDTO { */ amount?: BigNumberInput - /** - * The associated payment's ID. - */ - payment_id: string - - /** - * The associated refund reason's ID. - */ - refund_reason_id?: string | null - - /** - * A text field that adds some information about the refund - */ - note?: string - /** * Who refunded the payment. For example, * a user's ID. */ - created_by?: string + refunded_by?: string } /** @@ -238,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. */ @@ -248,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. */ @@ -269,19 +249,14 @@ export interface UpdatePaymentSessionDTO { id: string /** - * Necessary data for the associated payment provider to process the payment. - */ - data: Record - - /** - * The ISO 3 character currency code. + * The provider's payment method token */ - currency_code: string + provider_token?: string /** * The amount to be authorized. */ - amount: BigNumberInput + amount?: BigNumberInput /** * Necessary context data for the associated payment provider. @@ -289,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 bfeac41f11586..5b31fddd0db65 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 associated order's ID. + */ + order_id?: string /** - * The extra fields specific to the provider session. + * The associated customer detail */ - extra?: Record + 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 @@ -222,45 +220,109 @@ export interface IPaymentProvider { */ getIdentifier(): string + /** + * This methods sends a request to the third-party provider to initialize the payment. It's called when the payment session is created. + * + * 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 status and data or an error object. + */ initiatePayment( data: CreatePaymentProviderSession ): Promise + /** + * This method is used to update a payment associated with a session in the third-party provider. + * + * @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 null if successful. + */ deletePayment( paymentSessionData: Record - ): Promise + ): Promise + /** + * This method is called when a payment session should be authorized. + * You can interact with a third-party provider and perform the necessary actions to authorize the payment. + * + * 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 {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: + * + * - The payment provider supports automatically capturing the payment after authorization. + * - The merchant requests to capture the payment after its associated payment session was authorized. + * - A webhook event occurred that instructs the payment provider to capture the payment session. Learn more about handing webhook events in [this guide](https://docs.medusajs.com/experimental/payment/webhook-events/) + * + * 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 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 the Payment Session. + * @param {BigNumberInput} refundAmount - The amount to refund. + * @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 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 Session. + * @returns {Promise} Either the payment's status and data or an error object. + */ cancelPayment( paymentSessionData: Record - ): Promise - - getPaymentStatus( - paymentSessionData: Record - ): Promise + ): Promise 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 e521baa8e100e..d68a0ec89b43e 100644 --- a/packages/core/types/src/payment/service.ts +++ b/packages/core/types/src/payment/service.ts @@ -19,6 +19,7 @@ import { RefundReasonDTO, } from "./common" import { + AuthorizePaymentSessionDTO, CreateCaptureDTO, CreatePaymentCollectionDTO, CreatePaymentSessionDTO, @@ -466,7 +467,7 @@ export interface IPaymentModuleService extends IModuleService { * provider_id: "stripe", * currency_code: "usd", * amount: 3000, - * data: {}, + * context: {}, * } * ) */ @@ -502,7 +503,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") @@ -515,20 +516,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 @@ -664,35 +661,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 652ff1f5f45d0..04922e26613b9 100644 --- a/packages/core/utils/src/payment/abstract-payment-provider.ts +++ b/packages/core/utils/src/payment/abstract-payment-provider.ts @@ -1,15 +1,145 @@ 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 { + * // ... + * } + * ``` + * + * --- + * + * ## 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 + * + * The `PaymentProvider` data model has 2 properties: `id` and `is_enabled`. + * + * ```ts + * class MyPaymentProvider extends AbstractPaymentProvider { + * static identifier = "my-payment" + * // ... + * } + * ``` + * + * --- + * + * ## PROVIDER Property + * + * The `PROVIDER` static property is used when registering the provider in the module's container. Typically, it would have the + * same value as the `identifier` property. + * + * ```ts + * class MyPaymentProvider extends AbstractPaymentProvider { + * static PROVIDER = "my-payment" + * // ... + * } + * ``` + * + * --- + * + * ## PaymentProviderError + * + * Before diving into the methods of the Payment Provider, you'll notice that part of the expected return signature of these method includes `PaymentProviderError`. + * + * ```ts + * interface PaymentProviderError { + * error: string + * code?: string + * detail?: any + * } + * ``` + * + * While implementing the Payment Provider's methods, if you need to inform the Payment Module that an error occurred at a certain stage, + * return an object having the attributes defined in the `PaymentProviderError` interface. + * + * For example, the Stripe payment provider has the following method to create the error object, which is used within other methods: + * + * ```ts + * abstract class StripeBase extends AbstractPaymentProvider { + * // ... + * protected buildError( + * message: string, + * error: Stripe.StripeRawError | PaymentProviderError | Error + * ): PaymentProviderError { + * return { + * error: message, + * code: "code" in error ? error.code : "unknown", + * detail: isPaymentProviderError(error) + * ? `${error.error}${EOL}${error.detail ?? ""}` + * : "detail" in error + * ? error.detail + * : error.message ?? "", + * } + * } + * + * // used in other methods + * async retrievePayment( + * paymentSessionData: PaymentProviderSessionResponse["data"] + * ): Promise< + * PaymentProviderError | + * PaymentProviderSessionResponse["session_data"] + * > { + * try { + * // ... + * } catch (e) { + * return this.buildError( + * "An error occurred in retrievePayment", + * e + * ) + * } + * } + * } + * ``` + * + */ export abstract class AbstractPaymentProvider> implements IPaymentProvider { @@ -168,8 +298,9 @@ export abstract class AbstractPaymentProvider> * } */ abstract capturePayment( - paymentData: Record - ): Promise + paymentSessionData: PaymentProviderSessionResponse["data"], + captureAmount?: BigNumberInput + ): Promise /** * This method authorizes a payment session. When authorized successfully, a payment is created by the Payment @@ -233,21 +364,8 @@ export abstract class AbstractPaymentProvider> * } */ abstract authorizePayment( - paymentSessionData: Record, - context: Record - ): Promise< - | PaymentProviderError - | { - /** - * The new status of the payment. - */ - status: PaymentSessionStatus - /** - * The data to store in the created payment's `data` property. - */ - data: PaymentProviderSessionResponse["data"] - } - > + data: AuthorizePaymentProviderSession + ): Promise /** * This method cancels a payment. @@ -287,8 +405,8 @@ export abstract class AbstractPaymentProvider> * } */ abstract cancelPayment( - paymentData: Record - ): Promise + paymentSessionData: PaymentProviderSessionResponse["data"] + ): Promise /** * This method is used when a payment session is created. It can be used to initiate the payment @@ -342,7 +460,7 @@ export abstract class AbstractPaymentProvider> * } */ abstract initiatePayment( - context: CreatePaymentProviderSession + data: CreatePaymentProviderSession ): Promise /** @@ -387,55 +505,8 @@ export abstract class AbstractPaymentProvider> * } */ abstract deletePayment( - paymentSessionData: Record - ): Promise - - /** - * This method gets the status of a payment session based on the status in the third-party integration. - * - * @param paymentSessionData - The `data` property of the payment session. Make sure to store in it - * any helpful identification for your third-party integration. - * @returns The payment session's status. - * - * @example - * // other imports... - * import { - * PaymentSessionStatus - * } from "@medusajs/types" - * - * - * class MyPaymentProviderService extends AbstractPaymentProvider< - * Options - * > { - * async getPaymentStatus( - * paymentSessionData: Record - * ): Promise { - * const externalId = paymentSessionData.id - * - * try { - * const status = await this.client.getStatus(externalId) - * - * switch (status) { - * case "requires_capture": - * return "authorized" - * case "success": - * return "captured" - * case "canceled": - * return "canceled" - * default: - * return "pending" - * } - * } catch (e) { - * return "error" - * } - * } - * - * // ... - * } - */ - abstract getPaymentStatus( - paymentSessionData: Record - ): Promise + paymentSessionData: PaymentProviderSessionResponse["data"] + ): Promise /** * This method refunds an amount of a payment previously captured. @@ -487,9 +558,9 @@ export abstract class AbstractPaymentProvider> * } */ abstract refundPayment( - paymentData: Record, - refundAmount: number - ): Promise + paymentSessionData: PaymentProviderSessionResponse["data"], + refundAmount?: BigNumberInput + ): Promise /** * Retrieves the payment's data from the third-party service. @@ -531,8 +602,8 @@ export abstract class AbstractPaymentProvider> * } */ abstract retrievePayment( - paymentSessionData: Record - ): Promise + paymentSessionData: PaymentProviderSessionResponse["data"] + ): Promise /** * Update a payment in the third-party service that was previously initiated with the {@link initiatePayment} method. @@ -593,7 +664,7 @@ export abstract class AbstractPaymentProvider> * } */ abstract updatePayment( - context: UpdatePaymentProviderSession + data: UpdatePaymentProviderSession ): Promise /** @@ -668,7 +739,7 @@ export abstract class AbstractPaymentProvider> */ 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/payment-collections/[id]/payment-sessions/route.ts b/packages/medusa/src/api/store/payment-collections/[id]/payment-sessions/route.ts index 490a8b364283e..830407839acf4 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 @@ -12,7 +12,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) { @@ -22,7 +22,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 edc4dc993bb1a..865b6d2cd652d 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 f99c6410ce0fb..f98361dae391e 100644 --- a/packages/modules/payment/src/models/capture.ts +++ b/packages/modules/payment/src/models/capture.ts @@ -37,9 +37,6 @@ export default class Capture { }) payment!: Payment - @Property({ columnType: "jsonb", nullable: true }) - metadata: Record | null = null - @Property({ onCreate: () => new Date(), columnType: "timestamptz", @@ -65,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 aca053831db2c..5170c0cda5f2a 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 7114892350424..ed6e89fc3fef3 100644 --- a/packages/modules/payment/src/models/refund.ts +++ b/packages/modules/payment/src/models/refund.ts @@ -84,6 +84,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 98f36cdcceb67..da2c1987cf928 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, @@ -31,13 +32,11 @@ import { import { BigNumber, InjectManager, - InjectTransactionManager, isString, MathBN, MedusaContext, MedusaError, ModulesSdkUtils, - PaymentActions, PaymentCollectionStatus, PaymentSessionStatus, promiseAll, @@ -143,20 +142,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 @@ -204,11 +201,15 @@ export default class PaymentModuleService sharedContext ) - return await this.baseRepository_.serialize( + await Promise.all( + result.map(({ id }) => + this.maybeUpdatePaymentCollection_(id, sharedContext) + ) + ) + + return this.baseRepository_.serialize( Array.isArray(data) ? result : result[0], - { - populate: true, - } + { populate: true } ) } @@ -217,7 +218,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( @@ -253,9 +254,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( @@ -286,7 +287,7 @@ export default class PaymentModuleService sharedContext ) - return await this.baseRepository_.serialize( + return this.baseRepository_.serialize( Array.isArray(paymentCollectionId) ? updated : updated[0], { populate: true } ) @@ -295,75 +296,43 @@ export default class PaymentModuleService @InjectManager("baseRepository_") async createPaymentSession( paymentCollectionId: string, - input: CreatePaymentSessionDTO, + data: CreatePaymentSessionDTO, @MedusaContext() sharedContext?: Context ): Promise { - let paymentSession: PaymentSession | undefined - - 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 - ) - } + let paymentSession: PaymentSession + let res: PaymentProviderSessionResponse - 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_") @@ -371,23 +340,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_") @@ -401,10 +389,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) } @@ -412,109 +400,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, @@ -523,15 +487,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_") @@ -547,7 +502,7 @@ export default class PaymentModuleService sharedContext ) - return await this.baseRepository_.serialize(session) + return this.baseRepository_.serialize(session) } @InjectManager("baseRepository_") @@ -563,7 +518,7 @@ export default class PaymentModuleService sharedContext ) - return await this.baseRepository_.serialize(sessions) + return this.baseRepository_.serialize(sessions) } @InjectManager("baseRepository_") @@ -574,71 +529,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, @@ -647,28 +591,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.lte(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.` ) } @@ -676,144 +625,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.paymentService_.retrieve( - data.payment_id, - { - select: [ - "id", - "data", - "provider_id", - "payment_collection_id", - "amount", - "raw_amount", - ], - relations: ["captures.raw_amount", "refunds.raw_amount"], - }, + paymentId, + { select: ["provider_id", "data"] }, sharedContext ) - const refund = await this.refundPayment_(payment, data, sharedContext) - try { - await this.refundPaymentFromProvider_(payment, refund, 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_( - payment: Payment, - data: CreateRefundDTO, - @MedusaContext() sharedContext: Context = {} + paymentId: string, + data?: PaymentProviderSessionResponse["data"], + { amount, refunded_by }: CreateRefundDTO = {}, + @MedusaContext() sharedContext?: Context ): Promise { - 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 refundedAmount = payment.refunds.reduce((refundedAmount, next) => { - return MathBN.add(refundedAmount, next.raw_amount) - }, MathBN.convert(0)) + const payment = await this.paymentService_.retrieve( + paymentId, + { relations: ["captures", "refunds"] }, + sharedContext + ) - const totalRefundedAmount = MathBN.add(refundedAmount, 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, totalRefundedAmount)) { + if ( + MathBN.lte(refundableAmount, 0) || + MathBN.gt(refundAmount, refundableAmount) + ) { throw new MedusaError( MedusaError.Types.INVALID_DATA, `You cannot refund more than what is captured on the payment.` ) } + if (MathBN.lte(refundAmount, 0)) { + throw new MedusaError( + MedusaError.Types.INVALID_DATA, + `You must refund amount more than 0.` + ) + } + const refund = await this.refundService_.create( { - payment: data.payment_id, - amount: data.amount, - created_by: data.created_by, - note: data.note, - refund_reason_id: data.refund_reason_id, + payment: paymentId, + amount: refundAmount, + data, + created_by: refunded_by, }, sharedContext ) - return refund - } + await this.paymentService_.update({ id: paymentId, data }, sharedContext) - @InjectManager("baseRepository_") - private async refundPaymentFromProvider_( - payment: Payment, - refund: Refund, - @MedusaContext() sharedContext: Context = {} - ) { - const paymentData = await this.paymentProviderService_.refundPayment( - { - data: payment.data!, - provider_id: payment.provider_id, - }, - refund.raw_amount - ) - - await this.paymentService_.update( - { id: payment.id, data: paymentData }, - sharedContext - ) - - return payment + return refund } @InjectManager("baseRepository_") @@ -827,25 +747,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_") @@ -853,41 +763,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_") @@ -902,12 +784,7 @@ export default class PaymentModuleService sharedContext ) - return await this.baseRepository_.serialize( - providers, - { - populate: true, - } - ) + return this.baseRepository_.serialize(providers, { populate: true }) } @InjectManager("baseRepository_") @@ -923,13 +800,80 @@ 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 || + paymentSession.status === PaymentSessionStatus.PROCESSING + ) { + 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.lt(refundedAmount, capturedAmount)) { + status = PaymentSessionStatus.PARTIALLY_REFUNDED + } else { + status = PaymentSessionStatus.REFUNDED + } + } else if ( + MathBN.lt(capturedAmount, paymentSession.payment!.raw_amount) + ) { + status = PaymentSessionStatus.PARTIALLY_CAPTURED + } else { + status = PaymentSessionStatus.CAPTURED + } + } + } + + if (data || status !== paymentSession.status) { + await this.paymentSessionService_.update( + { id: paymentSession.id, data, status }, + sharedContext + ) + } + } + } + @InjectManager("baseRepository_") private async maybeUpdatePaymentCollection_( paymentCollectionId: string, @@ -938,65 +882,243 @@ 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", + "raw_authorized_amount", + "raw_captured_amount", + "raw_refunded_amount", ], + 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.lt(refundedAmount, capturedAmount)) { + status = PaymentCollectionStatus.PARTIALLY_REFUNDED + } else { + status = PaymentCollectionStatus.REFUNDED + } + } else if (MathBN.lt(capturedAmount, paymentCollection.raw_amount)) { + status = PaymentCollectionStatus.PARTIALLY_PAID + } else { + status = PaymentCollectionStatus.PAID + } + } else if (MathBN.gt(authorizedAmount, 0)) { + if (MathBN.lt(authorizedAmount, paymentCollection.raw_amount)) { + status = PaymentCollectionStatus.PARTIALLY_AUTHORIZED + } else { + status = PaymentCollectionStatus.AUTHORIZED } } - for (const capture of captures) { - capturedAmount = MathBN.add(capturedAmount, capture.amount) + if ( + status !== paymentCollection.status || + !MathBN.eq( + authorizedAmount, + paymentCollection.raw_authorized_amount || 0 + ) || + !MathBN.eq(capturedAmount, paymentCollection.raw_captured_amount || 0) || + !MathBN.eq(refundedAmount, paymentCollection.raw_refunded_amount || 0) + ) { + 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": { + const session = await this.paymentSessionService_.retrieve( + session_id, + { select: ["status"] }, + sharedContext + ) + if ( + (status === "processing" && + session.status !== PaymentSessionStatus.CANCELED) || + session.status === PaymentSessionStatus.PENDING || + session.status === PaymentSessionStatus.REQUIRES_MORE || + session.status === PaymentSessionStatus.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", "payment.captures"], + }, + sharedContext + ) + if (session.payment) { + if (session.payment.captures.length > 0) { + await this.paymentService_.update( + { + id: session.payment.id, + captured_at: new Date(), + data, + }, + sharedContext + ) + } else { + await this.paymentService_.update( + { + id: session.payment.id, + canceled_at: new Date(), + data, + }, + 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 0619ce8aa4b9b..da5d454f2363b 100644 --- a/packages/modules/providers/payment-stripe/src/core/stripe-base.ts +++ b/packages/modules/providers/payment-stripe/src/core/stripe-base.ts @@ -3,29 +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, isDefined, isPaymentProviderError, - isPresent, - MedusaError, - PaymentActions, - PaymentSessionStatus, } from "@medusajs/utils" -import { - ErrorCodes, - ErrorIntentStatus, - PaymentIntentOptions, - StripeOptions, -} from "../types" +import { ErrorCodes, PaymentIntentOptions, StripeOptions } from "../types" import { getAmountFromSmallestUnit, getSmallestUnit, @@ -77,56 +70,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({ @@ -142,56 +129,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) @@ -199,16 +194,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, + } } } @@ -217,138 +229,115 @@ 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 (isPresent(amount) && data.amount === amountNumeric) { - return { data } + const { id, currency } = data.data as unknown as Stripe.PaymentIntent + const { context, amount, token } = data + try { + 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, + } } - - 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) + 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, } + } catch (e) { + return this.buildError("An error occurred in updatePayment", e) } } - async updatePaymentData(sessionId: string, data: Record) { - try { - // Prevent from updating the amount from here as it should go through - // the updatePayment method to perform the correct logic - if (isPresent(data.amount)) { - throw new MedusaError( - MedusaError.Types.INVALID_DATA, - "Cannot update amount, use updatePayment instead" + async getWebhookActionAndData( + data: ProviderWebhookPayload["payload"] + ): Promise { + const event = this.constructWebhookEvent(data) + let intent: Stripe.PaymentIntent + + if (event.data.object.object === "payment_intent") { + intent = event.data.object + } else if (event.data.object.object === "charge") { + if (!event.data.object.payment_intent) { + throw new Error( + `Charge doesn't have a associated payment intent: ${event.type} ${event.data.object.id}.` ) } - - return (await this.stripe_.paymentIntents.update(sessionId, { - ...data, - })) as unknown as PaymentProviderSessionResponse["data"] - } catch (e) { - return this.buildError("An error occurred in updatePaymentData", e) + intent = await this.stripe_.paymentIntents.retrieve( + event.data.object.payment_intent as string, + { expand: ["latest_charge"] } + ) + } else if (event.data.object.object === "invoice") { + // TODO + throw new Error(`Unexpected event type: ${event.type}.`) + } else { + throw new Error(`Unexpected event type: ${event.type}.`) } - } - async getWebhookActionAndData( - webhookData: ProviderWebhookPayload["payload"] - ): Promise { - const event = this.constructWebhookEvent(webhookData) - 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, } } @@ -367,6 +356,7 @@ abstract class StripeBase extends AbstractPaymentProvider { this.options_.webhookSecret ) } + protected buildError( message: string, error: Stripe.StripeRawError | PaymentProviderError | Error @@ -381,6 +371,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 {