From 99a6ecc12d1b1ee1e6596de2e2c4deaeca4ad04a Mon Sep 17 00:00:00 2001 From: Stevche Radevski Date: Tue, 18 Feb 2025 11:04:25 +0100 Subject: [PATCH] feat: Add support to update account holder (#11499) --- packages/core/types/src/payment/mutations.ts | 33 +++++++++- packages/core/types/src/payment/provider.ts | 58 +++++++++++++++++ packages/core/types/src/payment/service.ts | 32 ++++++++++ .../payment/src/services/payment-module.ts | 41 ++++++++++++ .../payment/src/services/payment-provider.ts | 17 +++++ .../payment-stripe/src/core/stripe-base.ts | 62 +++++++++++++++++++ 6 files changed, 242 insertions(+), 1 deletion(-) diff --git a/packages/core/types/src/payment/mutations.ts b/packages/core/types/src/payment/mutations.ts index ec6b3591b1..4749991ac0 100644 --- a/packages/core/types/src/payment/mutations.ts +++ b/packages/core/types/src/payment/mutations.ts @@ -1,6 +1,10 @@ import { BigNumberInput } from "../totals" import { PaymentCollectionStatus } from "./common" -import { PaymentCustomerDTO, PaymentProviderContext } from "./provider" +import { + PaymentAccountHolderDTO, + PaymentCustomerDTO, + PaymentProviderContext, +} from "./provider" /** * The payment collection to be created. @@ -275,6 +279,33 @@ export interface CreateAccountHolderDTO { } } +export interface UpdateAccountHolderDTO { + /** + * The ID of the account holder. + */ + id: string + + /** + * The provider's ID. + */ + provider_id: string + + /** + * Necessary context data for the associated payment provider. + */ + context: PaymentProviderContext & { + /** + * The account holder information from Medusa. + */ + account_holder: PaymentAccountHolderDTO + } + + /** + * Holds custom data in key-value pairs. + */ + metadata?: Record +} + /** * The details of the webhook event payload. */ diff --git a/packages/core/types/src/payment/provider.ts b/packages/core/types/src/payment/provider.ts index d18ca915fc..da87314a6c 100644 --- a/packages/core/types/src/payment/provider.ts +++ b/packages/core/types/src/payment/provider.ts @@ -193,6 +193,18 @@ export type CreateAccountHolderInput = PaymentProviderInput & { } } +export type UpdateAccountHolderInput = PaymentProviderInput & { + /** + * The context of creating the account holder. + */ + context: PaymentProviderContext & { + /** + * The account holder's details. + */ + account_holder: PaymentAccountHolderDTO + } +} + /** * @interface * @@ -323,6 +335,8 @@ export type CreateAccountHolderOutput = PaymentProviderOutput & { id: string } +export type UpdateAccountHolderOutput = PaymentProviderOutput + export type DeleteAccountHolderOutput = PaymentProviderOutput export type ListPaymentMethodsOutput = (PaymentProviderOutput & { @@ -466,6 +480,50 @@ export interface IPaymentProvider { data: CreateAccountHolderInput ): Promise + /** + * This method is used when updating an account holder in Medusa, allowing you to update + * the equivalent account in the third-party service. + * + * 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. + * + * @param data - Input data including the details of the account holder to update. + * @returns The result of updating the account holder. If an error occurs, throw it. + * + * @version 2.6.0 + * + * @example + * import { MedusaError } from "@medusajs/framework/utils" + * + * class MyPaymentProviderService extends AbstractPaymentProvider< + * Options + * > { + * async updateAccountHolder({ context, data }: UpdateAccountHolderInput) { + * const { account_holder, customer } = context + * + * if (!account_holder?.data?.id) { + * throw new MedusaError( + * MedusaError.Types.INVALID_DATA, + * "Missing account holder ID." + * ) + * } + * + * // assuming you have a client that updates the account holder + * const providerAccountHolder = await this.client.updateAccountHolder({ + * id: account_holder.data.id, + * ...data + * }) + * + * return { + * id: providerAccountHolder.id, + * data: providerAccountHolder as unknown as Record + * } + * } + */ + updateAccountHolder?( + data: UpdateAccountHolderInput + ): Promise + /** * 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. diff --git a/packages/core/types/src/payment/service.ts b/packages/core/types/src/payment/service.ts index 3cc77c2ede..bd5ea6ac44 100644 --- a/packages/core/types/src/payment/service.ts +++ b/packages/core/types/src/payment/service.ts @@ -35,6 +35,7 @@ import { CreateAccountHolderDTO, UpsertPaymentCollectionDTO, CreatePaymentMethodDTO, + UpdateAccountHolderDTO, } from "./mutations" import { WebhookActionResult } from "./provider" @@ -788,6 +789,37 @@ export interface IPaymentModuleService extends IModuleService { sharedContext?: Context ): Promise + /** + * This method updates(if supported by provider) the account holder in the payment provider. + * + * @param {UpdateAccountHolderDTO} data - The details of the account holder. + * @param {Context} sharedContext - A context used to share resources, such as transaction manager, between the application and the module. + * @returns {Promise>} The account holder's details in the payment provider, typically just the ID. + * + * @example + * const accountHolder = + * await paymentModuleService.updateAccountHolder( + * { + * provider_id: "stripe", + * context: { + * account_holder: { + * data: { + * id: "acc_holder_123", + * }, + * }, + * customer: { + * id: "cus_123", + * company_name: "new_name", + * }, + * }, + * } + * ) + */ + updateAccountHolder( + input: UpdateAccountHolderDTO, + sharedContext?: Context + ): Promise + /** * This method deletes the account holder in the payment provider. * diff --git a/packages/modules/payment/src/services/payment-module.ts b/packages/modules/payment/src/services/payment-module.ts index f6a7a99c39..c42f595be8 100644 --- a/packages/modules/payment/src/services/payment-module.ts +++ b/packages/modules/payment/src/services/payment-module.ts @@ -36,6 +36,8 @@ import { WebhookActionResult, CreateAccountHolderOutput, InitiatePaymentOutput, + UpdateAccountHolderDTO, + UpdateAccountHolderOutput, } from "@medusajs/framework/types" import { BigNumber, @@ -1027,6 +1029,45 @@ export default class PaymentModuleService return await this.baseRepository_.serialize(accountHolder) } + @InjectManager() + async updateAccountHolder( + input: UpdateAccountHolderDTO, + @MedusaContext() sharedContext?: Context + ): Promise { + if (!input.context?.account_holder) { + throw new MedusaError( + MedusaError.Types.INVALID_DATA, + "Missing account holder data while updating account holder." + ) + } + + let accountHolder: InferEntityType | undefined + let providerAccountHolder: UpdateAccountHolderOutput | undefined + + providerAccountHolder = + await this.paymentProviderService_.updateAccountHolder( + input.provider_id, + { + context: input.context, + } + ) + + // The data field can be empty when either the method is not supported or an account holder wasn't updated + // We still want to do the update as we might only be updating the metadata + accountHolder = await this.accountHolderService_.update( + { + id: input.id, + ...(providerAccountHolder?.data + ? { data: providerAccountHolder.data } + : {}), + metadata: input.metadata, + }, + sharedContext + ) + + return await this.baseRepository_.serialize(accountHolder) + } + @InjectManager() async deleteAccountHolder( id: string, diff --git a/packages/modules/payment/src/services/payment-provider.ts b/packages/modules/payment/src/services/payment-provider.ts index 47fc782eb1..d70416deca 100644 --- a/packages/modules/payment/src/services/payment-provider.ts +++ b/packages/modules/payment/src/services/payment-provider.ts @@ -25,6 +25,8 @@ import { RefundPaymentOutput, SavePaymentMethodInput, SavePaymentMethodOutput, + UpdateAccountHolderInput, + UpdateAccountHolderOutput, UpdatePaymentInput, UpdatePaymentOutput, WebhookActionResult, @@ -149,6 +151,21 @@ Please make sure that the provider is registered in the container and it is conf return await provider.createAccountHolder(input) } + async updateAccountHolder( + providerId: string, + input: UpdateAccountHolderInput + ): Promise { + const provider = this.retrieveProvider(providerId) + if (!provider.updateAccountHolder) { + this.#logger.warn( + `Provider ${providerId} does not support updating account holders` + ) + return {} as unknown as UpdateAccountHolderOutput + } + + return await provider.updateAccountHolder(input) + } + async deleteAccountHolder( providerId: string, input: DeleteAccountHolderInput 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 a0dd0753c1..d079cf0f33 100644 --- a/packages/modules/providers/payment-stripe/src/core/stripe-base.ts +++ b/packages/modules/providers/payment-stripe/src/core/stripe-base.ts @@ -26,6 +26,8 @@ import { RetrievePaymentOutput, SavePaymentMethodInput, SavePaymentMethodOutput, + UpdateAccountHolderInput, + UpdateAccountHolderOutput, UpdatePaymentInput, UpdatePaymentOutput, WebhookActionResult, @@ -378,6 +380,66 @@ abstract class StripeBase extends AbstractPaymentProvider { } } + async updateAccountHolder({ + context, + }: UpdateAccountHolderInput): Promise { + const { account_holder, customer, idempotency_key } = context + + if (!account_holder?.data?.id) { + throw this.buildError( + "No account holder in context", + new Error("No account holder provided while updating account holder") + ) + } + + // If no customer context was provided, we simply don't update anything within the provider + if (!customer) { + return {} + } + + const accountHolderId = account_holder.data.id as string + + const shipping = customer.billing_address + ? ({ + address: { + city: customer.billing_address.city, + country: customer.billing_address.country_code, + line1: customer.billing_address.address_1, + line2: customer.billing_address.address_2, + postal_code: customer.billing_address.postal_code, + state: customer.billing_address.province, + }, + } as Stripe.CustomerCreateParams.Shipping) + : undefined + + try { + const stripeCustomer = await this.stripe_.customers.update( + accountHolderId, + { + email: customer.email, + name: + customer.company_name || + `${customer.first_name ?? ""} ${customer.last_name ?? ""}`.trim() || + undefined, + phone: customer.phone as string | undefined, + ...shipping, + }, + { + idempotencyKey: idempotency_key, + } + ) + + return { + data: stripeCustomer as unknown as Record, + } + } catch (e) { + throw this.buildError( + "An error occurred in updateAccountHolder when updating a Stripe customer", + e + ) + } + } + async deleteAccountHolder({ context, }: DeleteAccountHolderInput): Promise {