From f99f720dd4fd2b48e0d756e248cd1cb570bf9605 Mon Sep 17 00:00:00 2001 From: Stevche Radevski Date: Thu, 16 Jan 2025 16:16:04 +0100 Subject: [PATCH] feat: Add support for listing saved payment methods in module and Stripe (#10994) --- packages/core/types/src/payment/common.ts | 29 ++++++++++ packages/core/types/src/payment/provider.ts | 9 +++ .../src/payment/abstract-payment-provider.ts | 55 +++++++++++++++++++ packages/modules/payment/src/joiner-config.ts | 9 +++ .../modules/payment/src/providers/system.ts | 5 ++ .../payment/src/services/payment-module.ts | 29 ++++++++++ .../payment/src/services/payment-provider.ts | 10 ++++ .../payment-stripe/src/core/stripe-base.ts | 25 +++++++++ 8 files changed, 171 insertions(+) diff --git a/packages/core/types/src/payment/common.ts b/packages/core/types/src/payment/common.ts index e9ac20f0d6..2b3de29fd1 100644 --- a/packages/core/types/src/payment/common.ts +++ b/packages/core/types/src/payment/common.ts @@ -1,6 +1,7 @@ import { BaseFilterable } from "../dal" import { OperatorMap } from "../dal/utils" import { BigNumberValue } from "../totals" +import { PaymentProviderContext } from "./provider" /* ********** PAYMENT COLLECTION ********** */ export type PaymentCollectionStatus = @@ -245,6 +246,22 @@ export interface FilterableRefundProps extends BaseFilterable { */ deleted_at?: OperatorMap } + +/** + * The filters to apply on the retrieved payment sessions. + */ +export interface FilterablePaymentMethodProps { + /** + * Filter the payment methods by provider. + */ + provider_id: string + + /** + * Filter the payment methods by the context of their associated payment provider. + */ + context: PaymentProviderContext +} + /* ********** PAYMENT ********** */ export interface PaymentDTO { /** @@ -568,6 +585,18 @@ export interface PaymentProviderDTO { is_enabled: boolean } +export interface PaymentMethodDTO { + /** + * The ID of the payment method in the payment provider's system. + */ + id: string + + /** + * The data of the payment method, as returned by the payment provider. + */ + data: Record +} + /** * The filters to apply on the retrieved payment providers. */ diff --git a/packages/core/types/src/payment/provider.ts b/packages/core/types/src/payment/provider.ts index 0b627cc954..5a7f5b8820 100644 --- a/packages/core/types/src/payment/provider.ts +++ b/packages/core/types/src/payment/provider.ts @@ -135,6 +135,11 @@ export type PaymentProviderAuthorizeResponse = { data: PaymentProviderSessionResponse["data"] } +export type PaymentMethodResponse = { + id: string + data: Record +} + /** * @interface * @@ -249,6 +254,10 @@ export interface IPaymentProvider { paymentSessionData: Record ): Promise + listPaymentMethods( + context: PaymentProviderContext + ): Promise + getPaymentStatus( paymentSessionData: Record ): Promise diff --git a/packages/core/utils/src/payment/abstract-payment-provider.ts b/packages/core/utils/src/payment/abstract-payment-provider.ts index abcfc39f23..62e0fee02f 100644 --- a/packages/core/utils/src/payment/abstract-payment-provider.ts +++ b/packages/core/utils/src/payment/abstract-payment-provider.ts @@ -1,6 +1,8 @@ import { CreatePaymentProviderSession, IPaymentProvider, + PaymentMethodResponse, + PaymentProviderContext, PaymentProviderError, PaymentProviderSessionResponse, PaymentSessionStatus, @@ -623,6 +625,59 @@ export abstract class AbstractPaymentProvider> context: UpdatePaymentProviderSession ): Promise + /** + * List the payment methods associated with the context (eg. customer) of the payment provider, if any. + * + * @param context - The context for which the payment methods are listed. Usually the customer should be provided. + * @returns An object whose `payment_methods` property is set to the data returned by the payment provider. + * + * @example + * // other imports... + * import { + * PaymentProviderContext, + * PaymentProviderError, + * PaymentMethodResponse + * PaymentProviderSessionResponse, + * } from "@medusajs/framework/types" + * + * + * class MyPaymentProviderService extends AbstractPaymentProvider< + * Options + * > { + * async listPaymentMethods( + * context: PaymentProviderContext + * ): Promise { + * const { + * customer, + * } = context + * const externalCustomerId = customer.metadata.stripe_id + * + * try { + * // assuming you have a client that updates the payment + * const response = await this.client.listPaymentMethods( + * {customer: externalCustomerId} + * ) + * + * return response.map((method) => ({ + * id: method.id, + * data: method + * })) + * } catch (e) { + * return { + * error: e, + * code: "unknown", + * detail: e + * } + * } + * } + * + * // ... + * } + */ + abstract listPaymentMethods( + context: PaymentProviderContext + ): Promise + /** * This method is executed when a webhook event is received from the third-party payment provider. Use it * to process the action of the payment provider. diff --git a/packages/modules/payment/src/joiner-config.ts b/packages/modules/payment/src/joiner-config.ts index edadd259ae..f20a5dad61 100644 --- a/packages/modules/payment/src/joiner-config.ts +++ b/packages/modules/payment/src/joiner-config.ts @@ -23,4 +23,13 @@ export const joinerConfig = defineJoinerConfig(Modules.PAYMENT, { payment_provider_id: PaymentProvider.name, refund_reason_id: RefundReason.name, }, + alias: [ + { + name: ["payment_method", "payment_methods"], + entity: "PaymentMethod", + args: { + methodSuffix: "PaymentMethods", + }, + }, + ], }) diff --git a/packages/modules/payment/src/providers/system.ts b/packages/modules/payment/src/providers/system.ts index f5901bf449..f5765140e7 100644 --- a/packages/modules/payment/src/providers/system.ts +++ b/packages/modules/payment/src/providers/system.ts @@ -1,5 +1,6 @@ import { CreatePaymentProviderSession, + PaymentMethodResponse, PaymentProviderError, PaymentProviderSessionResponse, ProviderWebhookPayload, @@ -72,6 +73,10 @@ export class SystemProviderService extends AbstractPaymentProvider { return {} } + async listPaymentMethods(_): Promise { + return [] + } + async getWebhookActionAndData( data: ProviderWebhookPayload["payload"] ): Promise { diff --git a/packages/modules/payment/src/services/payment-module.ts b/packages/modules/payment/src/services/payment-module.ts index 225bbd25db..dc0f2f391d 100644 --- a/packages/modules/payment/src/services/payment-module.ts +++ b/packages/modules/payment/src/services/payment-module.ts @@ -8,6 +8,7 @@ import { CreateRefundDTO, DAL, FilterablePaymentCollectionProps, + FilterablePaymentMethodProps, FilterablePaymentProviderProps, FilterablePaymentSessionProps, FindConfig, @@ -20,6 +21,7 @@ import { PaymentCollectionDTO, PaymentCollectionUpdatableFields, PaymentDTO, + PaymentMethodDTO, PaymentProviderDTO, PaymentSessionDTO, ProviderWebhookPayload, @@ -906,6 +908,33 @@ export default class PaymentModuleService ] } + @InjectManager() + async listPaymentMethods( + filters: FilterablePaymentMethodProps, + config: FindConfig = {}, + @MedusaContext() sharedContext?: Context + ): Promise { + return await this.paymentProviderService_.listPaymentMethods( + filters.provider_id, + filters.context + ) + } + + @InjectManager() + async listAndCountPaymentMethods( + filters: FilterablePaymentMethodProps, + config: FindConfig = {}, + @MedusaContext() sharedContext?: Context + ): Promise<[PaymentMethodDTO[], number]> { + const paymentMethods = + await this.paymentProviderService_.listPaymentMethods( + filters.provider_id, + filters.context + ) + + return [paymentMethods, paymentMethods.length] + } + @InjectManager() private async maybeUpdatePaymentCollection_( paymentCollectionId: string, diff --git a/packages/modules/payment/src/services/payment-provider.ts b/packages/modules/payment/src/services/payment-provider.ts index 0930e30a05..fb75896092 100644 --- a/packages/modules/payment/src/services/payment-provider.ts +++ b/packages/modules/payment/src/services/payment-provider.ts @@ -4,7 +4,9 @@ import { DAL, IPaymentProvider, Logger, + PaymentMethodResponse, PaymentProviderAuthorizeResponse, + PaymentProviderContext, PaymentProviderDataInput, PaymentProviderError, PaymentProviderSessionResponse, @@ -150,6 +152,14 @@ Please make sure that the provider is registered in the container and it is conf return res as Record } + async listPaymentMethods( + providerId: string, + context: PaymentProviderContext + ): Promise { + const provider = this.retrieveProvider(providerId) + return await provider.listPaymentMethods(context) + } + async getWebhookActionAndData( providerId: string, data: ProviderWebhookPayload["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 08c9cb2474..3db9cf86cd 100644 --- a/packages/modules/providers/payment-stripe/src/core/stripe-base.ts +++ b/packages/modules/providers/payment-stripe/src/core/stripe-base.ts @@ -2,6 +2,8 @@ import Stripe from "stripe" import { CreatePaymentProviderSession, + PaymentMethodResponse, + PaymentProviderContext, PaymentProviderError, PaymentProviderSessionResponse, ProviderWebhookPayload, @@ -86,6 +88,8 @@ abstract class StripeBase extends AbstractPaymentProvider { res.payment_method = extra?.payment_method as string | undefined + res.return_url = extra?.return_url as string | undefined + return res } @@ -294,6 +298,27 @@ abstract class StripeBase extends AbstractPaymentProvider { } } + async listPaymentMethods( + context: PaymentProviderContext + ): Promise { + const customerId = context.customer?.metadata?.stripe_id + if (!customerId) { + return [] + } + + const paymentMethods = await this.stripe_.customers.listPaymentMethods( + customerId as string, + // In order to keep the interface simple, we just list the maximum payment methods, which should be enough in almost all cases. + // We can always extend the interface to allow additional filtering, if necessary. + { limit: 100 } + ) + + return paymentMethods.data.map((method) => ({ + id: method.id, + data: method as unknown as Record, + })) + } + async getWebhookActionAndData( webhookData: ProviderWebhookPayload["payload"] ): Promise {