diff --git a/integration-tests/http/__tests__/claims/claims.spec.ts b/integration-tests/http/__tests__/claims/claims.spec.ts index 5227da265f52b..f7181fcb0aa28 100644 --- a/integration-tests/http/__tests__/claims/claims.spec.ts +++ b/integration-tests/http/__tests__/claims/claims.spec.ts @@ -833,7 +833,7 @@ medusaIntegrationTestRunner({ expect.objectContaining({ currency_code: "usd", amount: paymentDelta, - status: "authorized", + status: "completed", authorized_amount: paymentDelta, captured_amount: paymentDelta, refunded_amount: 0, diff --git a/integration-tests/http/__tests__/order/admin/rma-flows.spec.ts b/integration-tests/http/__tests__/order/admin/rma-flows.spec.ts index f2ac856311b92..412d51a6974b7 100644 --- a/integration-tests/http/__tests__/order/admin/rma-flows.spec.ts +++ b/integration-tests/http/__tests__/order/admin/rma-flows.spec.ts @@ -346,8 +346,7 @@ medusaIntegrationTestRunner({ expect(paymentCollection).toEqual( expect.objectContaining({ amount: 200, - // Q: Shouldn't this be paid? - status: "authorized", + status: "completed", payment_sessions: [ expect.objectContaining({ status: "authorized", @@ -639,8 +638,7 @@ medusaIntegrationTestRunner({ expect(paymentCollection).toEqual( expect.objectContaining({ amount: 115.9, - // Q: Shouldn't this be paid? - status: "authorized", + status: "completed", payment_sessions: [ expect.objectContaining({ status: "authorized", 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 75ce6dd0a75ee..2dc55cdff6b20 100644 --- a/packages/core/core-flows/src/payment/steps/capture-payment.ts +++ b/packages/core/core-flows/src/payment/steps/capture-payment.ts @@ -38,4 +38,6 @@ export const capturePaymentStep = createStep( return new StepResponse(payment) } + // We don't want to compensate a capture automatically as the actual funds have already been taken. + // The only want to compensate here is to issue a refund, but it's better to leave that as a manual operation for now. ) 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 e91f8009df7fb..0e5c596bc90db 100644 --- a/packages/core/core-flows/src/payment/steps/refund-payment.ts +++ b/packages/core/core-flows/src/payment/steps/refund-payment.ts @@ -38,4 +38,6 @@ export const refundPaymentStep = createStep( return new StepResponse(payment) } + // We don't want to compensate a refund automatically as the actual funds have already been sent + // And in most cases we can't simply do another capture/authorization ) diff --git a/packages/core/types/src/http/payment/common.ts b/packages/core/types/src/http/payment/common.ts index 7aec966873b0e..18dc2c2378afb 100644 --- a/packages/core/types/src/http/payment/common.ts +++ b/packages/core/types/src/http/payment/common.ts @@ -9,7 +9,8 @@ export type BasePaymentCollectionStatus = | "authorized" | "partially_authorized" | "canceled" - + | "completed" + | "failed" /** * * The status of a payment session. diff --git a/packages/core/types/src/payment/common.ts b/packages/core/types/src/payment/common.ts index f67cb025cf8ca..ffe72c5967c50 100644 --- a/packages/core/types/src/payment/common.ts +++ b/packages/core/types/src/payment/common.ts @@ -10,6 +10,8 @@ export type PaymentCollectionStatus = | "authorized" | "partially_authorized" | "canceled" + | "failed" + | "completed" export type PaymentSessionStatus = | "authorized" diff --git a/packages/core/types/src/payment/provider.ts b/packages/core/types/src/payment/provider.ts index a13b59f3d4a38..d72bf939a2503 100644 --- a/packages/core/types/src/payment/provider.ts +++ b/packages/core/types/src/payment/provider.ts @@ -56,6 +56,9 @@ export type PaymentActions = | "authorized" | "captured" | "failed" + | "pending" + | "requires_more" + | "canceled" | "not_supported" /** @@ -123,28 +126,28 @@ export type UpdatePaymentInput = PaymentProviderInput & { /** * @interface - * + * * The data to delete a payment. */ export type DeletePaymentInput = PaymentProviderInput /** * @interface - * + * * The data to authorize a payment. */ export type AuthorizePaymentInput = PaymentProviderInput /** * @interface - * + * * The data to capture a payment. */ export type CapturePaymentInput = PaymentProviderInput /** * @interface - * + * * The data to refund a payment. */ export type RefundPaymentInput = PaymentProviderInput & { @@ -156,21 +159,21 @@ export type RefundPaymentInput = PaymentProviderInput & { /** * @interface - * + * * The data to retrieve a payment. */ export type RetrievePaymentInput = PaymentProviderInput /** * @interface - * + * * The data to cancel a payment. */ export type CancelPaymentInput = PaymentProviderInput /** * @interface - * + * * The data to create an account holder. */ export type CreateAccountHolderInput = PaymentProviderInput & { @@ -187,7 +190,7 @@ export type CreateAccountHolderInput = PaymentProviderInput & { /** * @interface - * + * * The data to delete an account holder. */ export type DeleteAccountHolderInput = PaymentProviderInput & { @@ -204,21 +207,21 @@ export type DeleteAccountHolderInput = PaymentProviderInput & { /** * @interface - * + * * The data to list payment methods. */ export type ListPaymentMethodsInput = PaymentProviderInput /** * @interface - * + * * The data to save a payment method. */ export type SavePaymentMethodInput = PaymentProviderInput /** * @interface - * + * * The data to get the payment status. */ export type GetPaymentStatusInput = PaymentProviderInput @@ -237,7 +240,7 @@ export type PaymentProviderOutput = { /** * @interface - * + * * The successful result of initiating a payment session using a third-party payment provider. */ export type InitiatePaymentOutput = PaymentProviderOutput & { @@ -261,49 +264,49 @@ export type AuthorizePaymentOutput = PaymentProviderOutput & { /** * @interface - * + * * The result of updating a payment. */ export type UpdatePaymentOutput = PaymentProviderOutput /** * @interface - * + * * The result of deleting a payment. */ export type DeletePaymentOutput = PaymentProviderOutput /** * @interface - * + * * The result of capturing the payment. */ export type CapturePaymentOutput = PaymentProviderOutput /** * @interface - * + * * The result of refunding the payment. */ export type RefundPaymentOutput = PaymentProviderOutput /** * @interface - * + * * The result of retrieving the payment. */ export type RetrievePaymentOutput = PaymentProviderOutput /** * @interface - * + * * The result of canceling the payment. */ export type CancelPaymentOutput = PaymentProviderOutput /** * @interface - * + * * The result of creating an account holder in the third-party payment provider. The `data` * property is stored as-is in Medusa's account holder's `data` property. */ @@ -326,7 +329,7 @@ export type ListPaymentMethodsOutput = (PaymentProviderOutput & { /** * @interface - * + * * The result of saving a payment method. */ export type SavePaymentMethodOutput = PaymentProviderOutput & { @@ -338,7 +341,7 @@ export type SavePaymentMethodOutput = PaymentProviderOutput & { /** * @interface - * + * * The result of getting the payment status. */ export type GetPaymentStatusOutput = PaymentProviderOutput & { @@ -408,46 +411,46 @@ export interface IPaymentProvider { /** * This method is used when creating an account holder in Medusa, allowing you to create - * the equivalent account in the third-party service. An account holder is useful to - * later save payment methods, such as credit cards, for a customer in the + * the equivalent account in the third-party service. An account holder is useful to + * later save payment methods, such as credit cards, for a customer in the * third-party payment provider using the {@link savePaymentMethod} method. - * + * * The returned data will be stored in the account holder created in Medusa. For example, * the returned `id` property will be stored in the account holder's `external_id` property. - * + * * Medusa creates an account holder when a payment session initialized for a registered customer. - * + * * @param data - Input data including the details of the account holder to create. * @returns The result of creating the account holder. If an error occurs, throw it. - * + * * @version 2.5.0 - * + * * @example * import { MedusaError } from "@medusajs/framework/utils" - * + * * class MyPaymentProviderService extends AbstractPaymentProvider< * Options * > { * async createAccountHolder({ context, data }: CreateAccountHolderInput) { * const { account_holder, customer } = context - * + * * if (account_holder?.data?.id) { * return { id: account_holder.data.id as string } * } - * + * * if (!customer) { * throw new MedusaError( * MedusaError.Types.INVALID_DATA, * "Missing customer data." * ) * } - * + * * // assuming you have a client that creates the account holder * const providerAccountHolder = await this.client.createAccountHolder({ * email: customer.email, * ...data * }) - * + * * return { * id: providerAccountHolder.id, * data: providerAccountHolder as unknown as Record @@ -461,15 +464,15 @@ export interface IPaymentProvider { /** * This method is used when an account holder is deleted in Medusa, allowing you * to also delete the equivalent account holder in the third-party service. - * + * * @param data - Input data including the details of the account holder to delete. * @returns The result of deleting the account holder. If an error occurs, throw it. - * + * * @version 2.5.0 - * + * * @example * import { MedusaError } from "@medusajs/framework/utils" - * + * * class MyPaymentProviderService extends AbstractPaymentProvider< * Options * > { @@ -482,12 +485,12 @@ export interface IPaymentProvider { * "Missing account holder ID." * ) * } - * + * * // assuming you have a client that deletes the account holder * await this.client.deleteAccountHolder({ * id: accountHolderId * }) - * + * * return {} * } * } @@ -500,34 +503,34 @@ export interface IPaymentProvider { * This method is used to retrieve the list of saved payment methods for an account holder * in the third-party payment provider. A payment provider that supports saving payment methods * must implement this method. - * + * * @version 2.5.0 - * + * * @param data - Input data including the details of the account holder to list payment methods for. * @returns The list of payment methods saved for the account holder. If an error occurs, throw it. - * + * * @example * import { MedusaError } from "@medusajs/framework/utils" - * + * * class MyPaymentProviderService extends AbstractPaymentProvider< * Options * > { * async listPaymentMethods({ context }: ListPaymentMethodsInput) { * const { account_holder } = context * const accountHolderId = account_holder?.data?.id as string | undefined - * + * * if (!accountHolderId) { * throw new MedusaError( * MedusaError.Types.INVALID_DATA, * "Missing account holder ID." * ) * } - * + * * // assuming you have a client that lists the payment methods * const paymentMethods = await this.client.listPaymentMethods({ * customer_id: accountHolderId * }) - * + * * return paymentMethods.map((pm) => ({ * id: pm.id, * data: pm as unknown as Record @@ -543,36 +546,36 @@ export interface IPaymentProvider { * This method is used to save a customer's payment method, such as a credit card, in the * third-party payment provider. A payment provider that supports saving payment methods * must implement this method. - * + * * @version 2.5.0 - * + * * @param data - The details of the payment method to save. * @returns The result of saving the payment method. If an error occurs, throw it. - * + * * @example * import { MedusaError } from "@medusajs/framework/utils" - * + * * class MyPaymentProviderService extends AbstractPaymentProvider< * Options * > { - * async savePaymentMethod({ context, data }: SavePaymentMethodInput) { * + * async savePaymentMethod({ context, data }: SavePaymentMethodInput) { * * const accountHolderId = context?.account_holder?.data?.id as * | string * | undefined - * + * * if (!accountHolderId) { * throw new MedusaError( * MedusaError.Types.INVALID_DATA, * "Missing account holder ID." * ) * } - * + * * // assuming you have a client that saves the payment method * const paymentMethod = await this.client.savePaymentMethod({ * customer_id: accountHolderId, * ...data * }) - * + * * return { * id: paymentMethod.id, * data: paymentMethod as unknown as Record diff --git a/packages/core/types/src/payment/service.ts b/packages/core/types/src/payment/service.ts index 2101832cd0213..3cc77c2edeabc 100644 --- a/packages/core/types/src/payment/service.ts +++ b/packages/core/types/src/payment/service.ts @@ -1003,6 +1003,21 @@ export interface IPaymentModuleService extends IModuleService { sharedContext?: Context ): Promise + /** + * This method deletes a capture by its ID. + * + * @param {string[]} captureId - The capture's ID. + * @param {Context} sharedContext - A context used to share resources, such as transaction manager, between the application and the module. + * @returns {Promise} Resolves when the capture is deleted successfully. + * + * @example + * await paymentModuleService.deleteCaptures([ + * "capt_123", + * "capt_321", + * ]) + */ + deleteCaptures(ids: string[], sharedContext?: Context): Promise + /** * This method retrieves a paginated list of refunds based on optional filters and configuration. * @@ -1055,6 +1070,21 @@ export interface IPaymentModuleService extends IModuleService { sharedContext?: Context ): Promise + /** + * This method deletes a refund by its ID. + * + * @param {string[]} refundId - The refund's ID. + * @param {Context} sharedContext - A context used to share resources, such as transaction manager, between the application and the module. + * @returns {Promise} Resolves when the refund is deleted successfully. + * + * @example + * await paymentModuleService.deleteRefunds([ + * "ref_123", + * "ref_321", + * ]) + */ + deleteRefunds(ids: string[], sharedContext?: Context): Promise + /** * This method creates refund reasons. * diff --git a/packages/core/utils/src/payment/payment-collection.ts b/packages/core/utils/src/payment/payment-collection.ts index 600445705f50d..ef89a03145f95 100644 --- a/packages/core/utils/src/payment/payment-collection.ts +++ b/packages/core/utils/src/payment/payment-collection.ts @@ -24,4 +24,12 @@ export enum PaymentCollectionStatus { * The payment collection is canceled. */ CANCELED = "canceled", + /** + * The payment collection is failed. + */ + FAILED = "failed", + /** + * The payment collection is completed. + */ + COMPLETED = "completed", } diff --git a/packages/core/utils/src/payment/webhook.ts b/packages/core/utils/src/payment/webhook.ts index b175e1821de0a..2ea9f54694c1a 100644 --- a/packages/core/utils/src/payment/webhook.ts +++ b/packages/core/utils/src/payment/webhook.ts @@ -3,7 +3,7 @@ export enum PaymentWebhookEvents { } /** - * Normalized events from payment provider to internal payment module events. + * Normalized events from payment provider to internal payment module events. In principle, these should match the payment status. */ export enum PaymentActions { /** @@ -18,6 +18,18 @@ export enum PaymentActions { * Payment failed. */ FAILED = "failed", + /** + * Payment is pending. + */ + PENDING = "pending", + /** + * Payment requires more information. + */ + REQUIRES_MORE = "requires_more", + /** + * Payment was canceled. + */ + CANCELED = "canceled", /** * Received an event that is not processable. */ diff --git a/packages/medusa/src/subscribers/payment-webhook.ts b/packages/medusa/src/subscribers/payment-webhook.ts index 895159adc3e3a..bd709f46d6edd 100644 --- a/packages/medusa/src/subscribers/payment-webhook.ts +++ b/packages/medusa/src/subscribers/payment-webhook.ts @@ -35,7 +35,14 @@ export default async function paymentWebhookhandler({ const processedEvent = await paymentService.getWebhookActionAndData(input) - if (processedEvent?.action === PaymentActions.NOT_SUPPORTED) { + if ( + processedEvent?.action === PaymentActions.NOT_SUPPORTED || + // Currently none of these are handled by the processPaymentWorkflow, so we ignore them. + // Remove once the processPaymentWorkflow is handling them. + processedEvent?.action === PaymentActions.CANCELED || + processedEvent?.action === PaymentActions.FAILED || + processedEvent?.action === PaymentActions.REQUIRES_MORE + ) { return } diff --git a/packages/modules/payment/integration-tests/__tests__/services/payment-module/index.spec.ts b/packages/modules/payment/integration-tests/__tests__/services/payment-module/index.spec.ts index 0dbcf0af14d71..5be98bbbd67b4 100644 --- a/packages/modules/payment/integration-tests/__tests__/services/payment-module/index.spec.ts +++ b/packages/modules/payment/integration-tests/__tests__/services/payment-module/index.spec.ts @@ -139,7 +139,7 @@ moduleIntegrationTestRunner({ amount: 200, authorized_amount: 200, captured_amount: 200, - status: "authorized", + status: "completed", deleted_at: null, completed_at: expect.any(Date), payment_sessions: [ diff --git a/packages/modules/payment/src/migrations/.snapshot-medusa-payment.json b/packages/modules/payment/src/migrations/.snapshot-medusa-payment.json index e05cc06831e51..7f6942a211008 100644 --- a/packages/modules/payment/src/migrations/.snapshot-medusa-payment.json +++ b/packages/modules/payment/src/migrations/.snapshot-medusa-payment.json @@ -209,7 +209,9 @@ "awaiting", "authorized", "partially_authorized", - "canceled" + "canceled", + "failed", + "completed" ], "mappedType": "enum" }, diff --git a/packages/modules/payment/src/migrations/Migration20250207132723.ts b/packages/modules/payment/src/migrations/Migration20250207132723.ts new file mode 100644 index 0000000000000..5a67e520005bb --- /dev/null +++ b/packages/modules/payment/src/migrations/Migration20250207132723.ts @@ -0,0 +1,17 @@ +import { Migration } from '@mikro-orm/migrations'; + +export class Migration20250207132723 extends Migration { + + override async up(): Promise { + this.addSql(`alter table if exists "payment_collection" drop constraint if exists "payment_collection_status_check";`); + + this.addSql(`alter table if exists "payment_collection" add constraint "payment_collection_status_check" check("status" in ('not_paid', 'awaiting', 'authorized', 'partially_authorized', 'canceled', 'failed', 'completed'));`); + } + + override async down(): Promise { + this.addSql(`alter table if exists "payment_collection" drop constraint if exists "payment_collection_status_check";`); + + this.addSql(`alter table if exists "payment_collection" add constraint "payment_collection_status_check" check("status" in ('not_paid', 'awaiting', 'authorized', 'partially_authorized', 'canceled'));`); + } + +} diff --git a/packages/modules/payment/src/models/payment.ts b/packages/modules/payment/src/models/payment.ts index ad3937eb6e0bd..7d71a899a80c2 100644 --- a/packages/modules/payment/src/models/payment.ts +++ b/packages/modules/payment/src/models/payment.ts @@ -4,6 +4,8 @@ import PaymentCollection from "./payment-collection" import PaymentSession from "./payment-session" import Refund from "./refund" +// TODO: We should remove the `Payment` model and use the `PaymentSession` model instead. +// We just need to move the refunds, captures, canceled_at, and captured_at to it. const Payment = model .define("Payment", { id: model.id({ prefix: "pay" }).primaryKey(), diff --git a/packages/modules/payment/src/services/payment-module.ts b/packages/modules/payment/src/services/payment-module.ts index 1056f03df7d0c..2f4a9634c5a41 100644 --- a/packages/modules/payment/src/services/payment-module.ts +++ b/packages/modules/payment/src/services/payment-module.ts @@ -12,7 +12,6 @@ import { FilterablePaymentCollectionProps, FilterablePaymentMethodProps, FilterablePaymentProviderProps, - FilterablePaymentSessionProps, FindConfig, InferEntityType, InternalModuleDeclaration, @@ -303,6 +302,7 @@ export default class PaymentModuleService sharedContext?: Context ): Promise + // Should we remove this and use `updatePaymentCollections` instead? @InjectManager() async completePaymentCollections( paymentCollectionId: string | string[], @@ -415,6 +415,12 @@ export default class PaymentModuleService sharedContext ) + await this.paymentProviderService_.updateSession(session.provider_id, { + data: data.data, + amount: data.amount, + currency_code: data.currency_code, + }) + const updated = await this.paymentSessionService_.update( { id: session.id, @@ -491,7 +497,7 @@ export default class PaymentModuleService ) { throw new MedusaError( MedusaError.Types.NOT_ALLOWED, - `Session: ${session.id} is not authorized with the provider.` + `Session: ${session.id} was not authorized with the provider.` ) } @@ -516,11 +522,9 @@ export default class PaymentModuleService sharedContext ) - return await this.retrievePayment( - payment.id, - { relations: ["payment_collection"] }, - sharedContext - ) + return await this.baseRepository_.serialize(payment, { + populate: true, + }) } @InjectTransactionManager() @@ -541,8 +545,11 @@ export default class PaymentModuleService id: session.id, data, status, - authorized_at: - status === PaymentSessionStatus.AUTHORIZED ? new Date() : null, + ...(session.authorized_at === null + ? { + authorized_at: new Date(), + } + : {}), }, sharedContext ) @@ -569,38 +576,6 @@ export default class PaymentModuleService return payment } - @InjectManager() - // @ts-expect-error - async retrievePaymentSession( - id: string, - config: FindConfig = {}, - @MedusaContext() sharedContext?: Context - ): Promise { - const session = await this.paymentSessionService_.retrieve( - id, - config, - sharedContext - ) - - return await this.baseRepository_.serialize(session) - } - - @InjectManager() - // @ts-expect-error - async listPaymentSessions( - filters?: FilterablePaymentSessionProps, - config?: FindConfig, - sharedContext?: Context - ): Promise { - const sessions = await this.paymentSessionService_.list( - filters, - config, - sharedContext - ) - - return await this.baseRepository_.serialize(sessions) - } - @InjectManager() async updatePayment( data: UpdatePaymentDTO, @@ -612,13 +587,33 @@ export default class PaymentModuleService return await this.baseRepository_.serialize(result[0]) } + // TODO: This method should return a capture, not a payment @InjectManager() async capturePayment( data: CreateCaptureDTO, @MedusaContext() sharedContext: Context = {} ): Promise { - const { payment, isFullyCaptured, capture } = await this.capturePayment_( + const payment = await this.paymentService_.retrieve( + data.payment_id, + { + select: [ + "id", + "data", + "provider_id", + "payment_collection_id", + "amount", + "raw_amount", + "captured_at", + "canceled_at", + ], + relations: ["captures.raw_amount"], + }, + sharedContext + ) + + const { isFullyCaptured, capture } = await this.capturePayment_( data, + payment, sharedContext ) @@ -640,45 +635,20 @@ export default class PaymentModuleService sharedContext ) - return await this.retrievePayment( - payment.id, - { relations: ["captures"] }, - sharedContext - ) + return await this.baseRepository_.serialize(payment, { + populate: true, + }) } @InjectTransactionManager() private async capturePayment_( data: CreateCaptureDTO, + payment: InferEntityType, @MedusaContext() sharedContext: Context = {} ): Promise<{ - payment: InferEntityType isFullyCaptured: boolean capture?: InferEntityType }> { - const payment = await this.paymentService_.retrieve( - data.payment_id, - { - select: [ - "id", - "data", - "provider_id", - "payment_collection_id", - "amount", - "raw_amount", - "captured_at", - "canceled_at", - ], - relations: ["captures.raw_amount"], - }, - 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, @@ -687,7 +657,12 @@ export default class PaymentModuleService } if (payment.captured_at) { - return { payment, isFullyCaptured: true } + return { isFullyCaptured: true } + } + + // If no custom amount is passed, we assume the full amount needs to be captured + if (!data.amount) { + data.amount = payment.amount as number } const capturedAmount = payment.captures.reduce((captureAmount, next) => { @@ -720,7 +695,7 @@ export default class PaymentModuleService sharedContext ) - return { payment, isFullyCaptured, capture } + return { isFullyCaptured, capture } } @InjectManager() private async capturePaymentFromProvider_( @@ -875,15 +850,79 @@ export default class PaymentModuleService } @InjectManager() - async getWebhookActionAndData( - eventData: ProviderWebhookPayload, - @MedusaContext() sharedContext?: Context - ): Promise { - const providerId = `pp_${eventData.provider}` + private async maybeUpdatePaymentCollection_( + paymentCollectionId: string, + sharedContext?: Context + ) { + 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", + ], + }, + sharedContext + ) - return await this.paymentProviderService_.getWebhookActionAndData( - providerId, - eventData.payload + const paymentSessions = paymentCollection.payment_sessions + const captures = paymentCollection.payments + .map((pay) => [...pay.captures]) + .flat() + const refunds = paymentCollection.payments + .map((pay) => [...pay.refunds]) + .flat() + + let authorizedAmount = MathBN.convert(0) + let capturedAmount = MathBN.convert(0) + let refundedAmount = MathBN.convert(0) + let completedAt: Date | undefined + + for (const ps of paymentSessions) { + if (ps.status === PaymentSessionStatus.AUTHORIZED) { + authorizedAmount = MathBN.add(authorizedAmount, ps.amount) + } + } + + for (const capture of captures) { + capturedAmount = MathBN.add(capturedAmount, capture.amount) + } + + for (const refund of refunds) { + refundedAmount = MathBN.add(refundedAmount, refund.amount) + } + + let status = + paymentSessions.length === 0 + ? PaymentCollectionStatus.NOT_PAID + : PaymentCollectionStatus.AWAITING + + if (MathBN.gt(authorizedAmount, 0)) { + status = MathBN.gte(authorizedAmount, paymentCollection.amount) + ? PaymentCollectionStatus.AUTHORIZED + : PaymentCollectionStatus.PARTIALLY_AUTHORIZED + } + + if (MathBN.eq(paymentCollection.amount, capturedAmount)) { + status = PaymentCollectionStatus.COMPLETED + completedAt = new Date() + } + + await this.paymentCollectionService_.update( + { + id: paymentCollectionId, + status, + authorized_amount: authorizedAmount, + captured_amount: capturedAmount, + refunded_amount: refundedAmount, + completed_at: completedAt, + }, + sharedContext ) } @@ -939,44 +978,23 @@ export default class PaymentModuleService let accountHolder: InferEntityType | undefined let providerAccountHolder: CreateAccountHolderOutput | undefined - try { - providerAccountHolder = - await this.paymentProviderService_.createAccountHolder( - input.provider_id, - { context: input.context } - ) - - // This can be empty when either the method is not supported or an account holder wasn't created - if (isPresent(providerAccountHolder)) { - accountHolder = await this.accountHolderService_.create( - { - external_id: providerAccountHolder.id, - email: input.context.customer?.email, - data: providerAccountHolder.data, - provider_id: input.provider_id, - }, - sharedContext - ) - } - } catch (error) { - if (providerAccountHolder) { - await this.paymentProviderService_.deleteAccountHolder( - input.provider_id, - { - context: { - account_holder: providerAccountHolder as { - data: Record - }, - }, - } - ) - } - - if (accountHolder) { - await this.accountHolderService_.delete(accountHolder.id, sharedContext) - } + providerAccountHolder = + await this.paymentProviderService_.createAccountHolder( + input.provider_id, + { context: input.context } + ) - throw error + // This can be empty when either the method is not supported or an account holder wasn't created + if (isPresent(providerAccountHolder)) { + accountHolder = await this.accountHolderService_.create( + { + external_id: providerAccountHolder.id, + email: input.context.customer?.email, + data: providerAccountHolder.data, + provider_id: input.provider_id, + }, + sharedContext + ) } return await this.baseRepository_.serialize(accountHolder) @@ -1078,72 +1096,15 @@ export default class PaymentModuleService } @InjectManager() - private async maybeUpdatePaymentCollection_( - paymentCollectionId: string, - sharedContext?: Context - ) { - 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", - ], - }, - sharedContext - ) - - const paymentSessions = paymentCollection.payment_sessions - const captures = paymentCollection.payments - .map((pay) => [...pay.captures]) - .flat() - const refunds = paymentCollection.payments - .map((pay) => [...pay.refunds]) - .flat() - - 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) - } - } - - for (const capture of captures) { - capturedAmount = MathBN.add(capturedAmount, capture.amount) - } - - for (const refund of refunds) { - refundedAmount = MathBN.add(refundedAmount, refund.amount) - } - - let status = - paymentSessions.length === 0 - ? PaymentCollectionStatus.NOT_PAID - : PaymentCollectionStatus.AWAITING - - if (MathBN.gt(authorizedAmount, 0)) { - status = MathBN.gte(authorizedAmount, paymentCollection.amount) - ? PaymentCollectionStatus.AUTHORIZED - : PaymentCollectionStatus.PARTIALLY_AUTHORIZED - } + async getWebhookActionAndData( + eventData: ProviderWebhookPayload, + @MedusaContext() sharedContext?: Context + ): Promise { + const providerId = `pp_${eventData.provider}` - await this.paymentCollectionService_.update( - { - id: paymentCollectionId, - status, - authorized_amount: authorizedAmount, - captured_amount: capturedAmount, - refunded_amount: refundedAmount, - }, - sharedContext + return await this.paymentProviderService_.getWebhookActionAndData( + providerId, + eventData.payload ) } } 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 2d0f7bb0801eb..3ce4beb7107e5 100644 --- a/packages/modules/providers/payment-stripe/src/core/stripe-base.ts +++ b/packages/modules/providers/payment-stripe/src/core/stripe-base.ts @@ -125,22 +125,30 @@ abstract class StripeBase extends AbstractPaymentProvider { } const paymentIntent = await this.stripe_.paymentIntents.retrieve(id) + const dataResponse = paymentIntent as unknown as Record switch (paymentIntent.status) { case "requires_payment_method": + if (paymentIntent.last_payment_error) { + return { status: PaymentSessionStatus.ERROR, data: dataResponse } + } + return { status: PaymentSessionStatus.PENDING, data: dataResponse } case "requires_confirmation": case "processing": - return { status: PaymentSessionStatus.PENDING } + return { status: PaymentSessionStatus.PENDING, data: dataResponse } case "requires_action": - return { status: PaymentSessionStatus.REQUIRES_MORE } + return { + status: PaymentSessionStatus.REQUIRES_MORE, + data: dataResponse, + } case "canceled": - return { status: PaymentSessionStatus.CANCELED } + return { status: PaymentSessionStatus.CANCELED, data: dataResponse } case "requires_capture": - return { status: PaymentSessionStatus.AUTHORIZED } + return { status: PaymentSessionStatus.AUTHORIZED, data: dataResponse } case "succeeded": - return { status: PaymentSessionStatus.CAPTURED } + return { status: PaymentSessionStatus.CAPTURED, data: dataResponse } default: - return { status: PaymentSessionStatus.PENDING } + return { status: PaymentSessionStatus.PENDING, data: dataResponse } } } @@ -419,24 +427,23 @@ abstract class StripeBase extends AbstractPaymentProvider { const intent = event.data.object as Stripe.PaymentIntent const { currency } = intent + switch (event.type) { - case "payment_intent.amount_capturable_updated": + case "payment_intent.created": + case "payment_intent.processing": return { - action: PaymentActions.AUTHORIZED, + action: PaymentActions.PENDING, data: { session_id: intent.metadata.session_id, - amount: getAmountFromSmallestUnit( - intent.amount_capturable, - currency - ), // NOTE: revisit when implementing multicapture + amount: getAmountFromSmallestUnit(intent.amount, currency), }, } - case "payment_intent.succeeded": + case "payment_intent.canceled": return { - action: PaymentActions.SUCCESSFUL, + action: PaymentActions.CANCELED, data: { session_id: intent.metadata.session_id, - amount: getAmountFromSmallestUnit(intent.amount_received, currency), + amount: getAmountFromSmallestUnit(intent.amount, currency), }, } case "payment_intent.payment_failed": @@ -447,6 +454,34 @@ abstract class StripeBase extends AbstractPaymentProvider { amount: getAmountFromSmallestUnit(intent.amount, currency), }, } + case "payment_intent.requires_action": + return { + action: PaymentActions.REQUIRES_MORE, + data: { + session_id: intent.metadata.session_id, + amount: getAmountFromSmallestUnit(intent.amount, currency), + }, + } + case "payment_intent.amount_capturable_updated": + return { + action: PaymentActions.AUTHORIZED, + data: { + session_id: intent.metadata.session_id, + amount: getAmountFromSmallestUnit( + intent.amount_capturable, + currency + ), + }, + } + case "payment_intent.succeeded": + return { + action: PaymentActions.SUCCESSFUL, + data: { + session_id: intent.metadata.session_id, + amount: getAmountFromSmallestUnit(intent.amount_received, currency), + }, + } + default: return { action: PaymentActions.NOT_SUPPORTED } }