diff --git a/integration-tests/modules/__tests__/cart/store/cart.workflows.spec.ts b/integration-tests/modules/__tests__/cart/store/cart.workflows.spec.ts index 9ee3d811de612..3274f93a05584 100644 --- a/integration-tests/modules/__tests__/cart/store/cart.workflows.spec.ts +++ b/integration-tests/modules/__tests__/cart/store/cart.workflows.spec.ts @@ -2543,7 +2543,6 @@ medusaIntegrationTestRunner({ await paymentModule.createPaymentCollections({ amount: 5001, currency_code: "dkk", - region_id: defaultRegion.id, }) const paymentSession = await paymentModule.createPaymentSession( @@ -2615,7 +2614,6 @@ medusaIntegrationTestRunner({ await paymentModule.createPaymentCollections({ amount: 5000, currency_code: "dkk", - region_id: defaultRegion.id, }) const paymentSession = await paymentModule.createPaymentSession( diff --git a/integration-tests/modules/__tests__/cart/store/carts.spec.ts b/integration-tests/modules/__tests__/cart/store/carts.spec.ts index 1c19c8cced1aa..cf2e908c8a043 100644 --- a/integration-tests/modules/__tests__/cart/store/carts.spec.ts +++ b/integration-tests/modules/__tests__/cart/store/carts.spec.ts @@ -1330,7 +1330,6 @@ medusaIntegrationTestRunner({ ).data.shipping_option paymentCollection = await paymentService.createPaymentCollections({ - region_id: region.id, amount: 1000, currency_code: "usd", }) 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 ef057abace59c..e8d2f1aee76ee 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 @@ -26,14 +26,14 @@ export type ThrowUnlessPaymentCollectionNotePaidInput = { /** * This step validates that the payment collection is not paid. If not valid, * the step will throw an error. - * + * * :::note - * + * * You can retrieve a payment collection's details using [Query](https://docs.medusajs.com/learn/fundamentals/module-links/query), * or [useQueryGraphStep](https://docs.medusajs.com/resources/references/medusa-workflows/steps/useQueryGraphStep). - * + * * ::: - * + * * @example * const data = throwUnlessPaymentCollectionNotPaid({ * paymentCollection: { @@ -77,10 +77,10 @@ export const markPaymentCollectionAsPaidId = "mark-payment-collection-as-paid" /** * This workflow marks a payment collection for an order as paid. It's used by the * [Mark Payment Collection as Paid Admin API Route](https://docs.medusajs.com/api/admin#payment-collections_postpaymentcollectionsidmarkaspaid). - * + * * You can use this workflow within your customizations or your own custom workflows, allowing you to wrap custom logic around * marking a payment collection for an order as paid. - * + * * @example * const { result } = await markPaymentCollectionAsPaid(container) * .run({ @@ -89,16 +89,14 @@ export const markPaymentCollectionAsPaidId = "mark-payment-collection-as-paid" * payment_collection_id: "paycol_123", * } * }) - * + * * @summary - * + * * Mark a payment collection for an order as paid. */ export const markPaymentCollectionAsPaid = createWorkflow( markPaymentCollectionAsPaidId, - ( - input: WorkflowData - ) => { + (input: WorkflowData) => { const paymentCollection = useRemoteQueryStep({ entry_point: "payment_collection", fields: ["id", "status", "amount"], diff --git a/packages/core/core-flows/src/payment-collection/steps/create-payment-account-holder.ts b/packages/core/core-flows/src/payment-collection/steps/create-payment-account-holder.ts new file mode 100644 index 0000000000000..0ab8616f0142d --- /dev/null +++ b/packages/core/core-flows/src/payment-collection/steps/create-payment-account-holder.ts @@ -0,0 +1,43 @@ +import { + IPaymentModuleService, + CreateAccountHolderDTO, +} from "@medusajs/framework/types" +import { Modules } from "@medusajs/framework/utils" +import { StepResponse, createStep } from "@medusajs/framework/workflows-sdk" + +export const createPaymentAccountHolderStepId = "create-payment-account-holder" +/** + * This step creates the account holder in the payment provider. + */ +export const createPaymentAccountHolderStep = createStep( + createPaymentAccountHolderStepId, + async (data: CreateAccountHolderDTO, { container }) => { + const service = container.resolve(Modules.PAYMENT) + + const accountHolder = await service.createAccountHolder(data) + + return new StepResponse(accountHolder, { input: data, accountHolder }) + }, + async (createdAccountHolder, { container }) => { + if (!createdAccountHolder) { + return + } + + const service = container.resolve(Modules.PAYMENT) + const input = { + provider_id: createdAccountHolder.input.provider_id, + context: { + ...createdAccountHolder.input.context, + customer: { + ...createdAccountHolder.input.context?.customer, + metadata: { + ...createdAccountHolder.input.context?.customer?.metadata, + ...createdAccountHolder.accountHolder, + }, + }, + }, + } + + await service.deleteAccountHolder(input) + } +) 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 6f5c9d69421e7..5e7efe58ee33d 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 @@ -24,7 +24,7 @@ export interface CreatePaymentSessionStepInput { amount: BigNumberInput /** * The currency code of the payment session. - * + * * @example * usd */ @@ -42,7 +42,7 @@ export interface CreatePaymentSessionStepInput { export const createPaymentSessionStepId = "create-payment-session" /** - * This step creates a payment session. + * This step creates a payment session. */ export const createPaymentSessionStep = createStep( createPaymentSessionStepId, diff --git a/packages/core/core-flows/src/payment-collection/steps/index.ts b/packages/core/core-flows/src/payment-collection/steps/index.ts index 8e1be26418eda..ed94bd173f5c4 100644 --- a/packages/core/core-flows/src/payment-collection/steps/index.ts +++ b/packages/core/core-flows/src/payment-collection/steps/index.ts @@ -5,3 +5,4 @@ export * from "./delete-refund-reasons" export * from "./update-payment-collection" export * from "./update-refund-reasons" export * from "./validate-deleted-payment-sessions" +export * from "./create-payment-account-holder" 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 53b227caa25cd..b2e1c498942c5 100644 --- a/packages/core/core-flows/src/payment-collection/workflows/create-payment-session.ts +++ b/packages/core/core-flows/src/payment-collection/workflows/create-payment-session.ts @@ -1,4 +1,7 @@ import { + AddressDTO, + CustomerDTO, + PaymentCustomerDTO, PaymentProviderContext, PaymentSessionDTO, } from "@medusajs/framework/types" @@ -8,10 +11,16 @@ import { createWorkflow, parallelize, transform, + when, } from "@medusajs/framework/workflows-sdk" import { useRemoteQueryStep } from "../../common" -import { createPaymentSessionStep } from "../steps" +import { + createPaymentSessionStep, + createPaymentAccountHolderStep, +} from "../steps" import { deletePaymentSessionsWorkflow } from "./delete-payment-sessions" +import { updateCustomersStep } from "../../customer" +import { isPresent } from "@medusajs/framework/utils" /** * The data to create payment sessions. @@ -26,6 +35,10 @@ export interface CreatePaymentSessionsWorkflowInput { * This provider is used to later process the payment sessions and their payments. */ provider_id: string + /** + * The ID of the customer that the payment session should be associated with. + */ + customer_id?: string /** * Custom data relevant for the payment provider to process the payment session. * Learn more in [this documentation](https://docs.medusajs.com/resources/commerce-modules/payment/payment-session#data-property). @@ -41,10 +54,10 @@ export const createPaymentSessionsWorkflowId = "create-payment-sessions" /** * This workflow creates payment sessions. It's used by the * [Initialize Payment Session Store API Route](https://docs.medusajs.com/api/store#payment-collections_postpaymentcollectionsidpaymentsessions). - * + * * You can use this workflow within your own customizations or custom workflows, allowing you * to create payment sessions in your custom flows. - * + * * @example * const { result } = await createPaymentSessionsWorkflow(container) * .run({ @@ -53,9 +66,9 @@ export const createPaymentSessionsWorkflowId = "create-payment-sessions" * provider_id: "pp_system" * } * }) - * + * * @summary - * + * * Create payment sessions. */ export const createPaymentSessionsWorkflow = createWorkflow( @@ -68,16 +81,93 @@ export const createPaymentSessionsWorkflow = createWorkflow( fields: ["id", "amount", "currency_code", "payment_sessions.*"], variables: { id: input.payment_collection_id }, list: false, + }).config({ name: "get-payment-collection" }) + + const { customer, accountHolder } = when( + "customer-id-exists", + { input }, + (data) => { + return !!data.input.customer_id + } + ).then(() => { + const customer: CustomerDTO = useRemoteQueryStep({ + entry_point: "customer", + fields: [ + "id", + "email", + "company_name", + "first_name", + "last_name", + "phone", + "addresses.*", + "metadata", + ], + variables: { id: input.customer_id }, + list: false, + }).config({ name: "get-customer" }) + + const accountHolderInput = transform({ input, customer }, (data) => { + return { + provider_id: data.input.provider_id, + context: { + ...data.input.context, + customer: data.customer, + }, + } + }) + + const accountHolder = createPaymentAccountHolderStep(accountHolderInput) + return { customer, accountHolder } + }) + + const updatedCustomer = when( + "account-holder-exists", + { accountHolder }, + (data) => { + return isPresent(data.accountHolder) + } + ).then(() => { + updateCustomersStep({ + selector: { + id: input.context?.customer?.id, + }, + update: { + metadata: accountHolder, + }, + }) + + const updatedCustomer = transform({ customer, accountHolder }, (data) => { + return { + ...data.customer, + metadata: { + ...data.customer.metadata, + ...data.accountHolder, + }, + } + }) + + return updatedCustomer + }) + + const updatedContext = transform({ input, updatedCustomer }, (data) => { + return { + ...data.input.context, + email: data.input.context?.email ?? data.updatedCustomer?.email, + billing_address: (data.input.context?.billing_address ?? + data.updatedCustomer?.addresses?.find((a) => a.is_default_billing) ?? + data.updatedCustomer?.addresses?.[0]) as Partial, + customer: data.updatedCustomer as PaymentCustomerDTO, + } }) const paymentSessionInput = transform( - { paymentCollection, input }, + { paymentCollection, updatedContext, input }, (data) => { return { payment_collection_id: data.input.payment_collection_id, provider_id: data.input.provider_id, data: data.input.data, - context: data.input.context, + context: data.updatedContext ?? data.input.context, amount: data.paymentCollection.amount, currency_code: data.paymentCollection.currency_code, } diff --git a/packages/core/types/src/payment/mutations.ts b/packages/core/types/src/payment/mutations.ts index d1bc6c92c7362..e64ebb91d909f 100644 --- a/packages/core/types/src/payment/mutations.ts +++ b/packages/core/types/src/payment/mutations.ts @@ -255,6 +255,33 @@ export interface CreatePaymentProviderDTO { is_enabled?: boolean } +/** + * The payment session to be created. + */ +export interface CreateAccountHolderDTO { + /** + * The provider's ID. + */ + provider_id: string + + /** + * Necessary context data for the associated payment provider. + */ + context: PaymentProviderContext +} + +export interface DeleteAccountHolderDTO { + /** + * The provider's ID. + */ + provider_id: string + + /** + * Necessary context data for the associated payment provider. + */ + context: PaymentProviderContext +} + /** * 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 5a7f5b882021d..5a6ae4c4b08ae 100644 --- a/packages/core/types/src/payment/provider.ts +++ b/packages/core/types/src/payment/provider.ts @@ -118,6 +118,19 @@ export type PaymentProviderSessionResponse = { data: Record } +/** + * @interface + * + * The response of operations on a payment account holder. + */ +export type PaymentAccountHolderResponse = { + /** + * The data field can be stored in eg. `customer.metadata`, which should then be passed to other methods as part of the context. + * The field can contain external customer IDs, and other information that is necessary for the account holder when performing a payment. + */ + data: Record +} + /** * @interface * @@ -254,6 +267,14 @@ export interface IPaymentProvider { paymentSessionData: Record ): Promise + createAccountHolder( + context: PaymentProviderContext + ): Promise + + deleteAccountHolder( + context: PaymentProviderContext + ): Promise + listPaymentMethods( context: PaymentProviderContext ): Promise diff --git a/packages/core/types/src/payment/service.ts b/packages/core/types/src/payment/service.ts index f19d8b0f40955..4c84018a55810 100644 --- a/packages/core/types/src/payment/service.ts +++ b/packages/core/types/src/payment/service.ts @@ -6,6 +6,7 @@ import { CaptureDTO, FilterableCaptureProps, FilterablePaymentCollectionProps, + FilterablePaymentMethodProps, FilterablePaymentProps, FilterablePaymentProviderProps, FilterablePaymentSessionProps, @@ -13,6 +14,7 @@ import { FilterableRefundReasonProps, PaymentCollectionDTO, PaymentDTO, + PaymentMethodDTO, PaymentProviderDTO, PaymentSessionDTO, RefundDTO, @@ -29,7 +31,9 @@ import { UpdatePaymentDTO, UpdatePaymentSessionDTO, UpdateRefundReasonDTO, + CreateAccountHolderDTO, UpsertPaymentCollectionDTO, + DeleteAccountHolderDTO, } from "./mutations" import { WebhookActionResult } from "./provider" @@ -749,6 +753,137 @@ export interface IPaymentModuleService extends IModuleService { sharedContext?: Context ): Promise<[PaymentProviderDTO[], number]> + /** + * This method creates(if supported by provider) the account holder in the payment provider. + * + * @param {CreateAccountHolderDTO} 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.createAccountHolder( + * { + * provider_id: "stripe", + * context: { + * customer: { + * id: "cus_123", + * metadata: { + * pp_stripe_stripe_customer_id: "str_1234" + * } + * }, + * }, + * } + * ) + * + * await customerModuleService.updateCustomer("cus_123", { + * metadata: { + * ...accountHolder + * } + * }) + */ + createAccountHolder( + input: CreateAccountHolderDTO, + sharedContext?: Context + ): Promise> + + /** + * This method deletes the account holder in the payment provider. + * + * @param {DeleteAccountHolderDTO} input - The input to delete the account holder. + * @param {Context} sharedContext - A context used to share resources, such as transaction manager, between the application and the module. + * @returns {Promise} Resolves when the account holder is deleted successfully. + * + * @example + * const accountHolderResetFields = await paymentModuleService.deleteAccountHolder({ + * provider_id: "pp_stripe_stripe", + * context: { + * customer: { + * id: "cus_123", + * metadata: { + * pp_stripe_stripe_customer_id: "str_1234" + * } + * }, + * } + * }) + * + * await customerModuleService.updateCustomer("cus_123", { + * metadata: accountHolderResetFields + * }) + */ + deleteAccountHolder( + input: DeleteAccountHolderDTO, + sharedContext?: Context + ): Promise> + + /** + * This method retrieves all payment methods based on the context and configuration. + * + * @param {FilterablePaymentMethodProps} filters - The filters to apply on the retrieved payment methods. + * @param {FindConfig} config - The configurations determining how the payment method is retrieved. Its properties, such as `select` or `relations`, accept the + * attributes or relations associated with a payment method. + * @param {Context} sharedContext - A context used to share resources, such as transaction manager, between the application and the module. + * @returns {Promise} The list of payment methods. + * + * @example + * To retrieve a list of payment methods for a customer: + * + * ```ts + * const paymentMethods = + * await paymentModuleService.listPaymentMethods({ + * provider_id: "pp_stripe_stripe", + * context: { + * customer: { + * id: "cus_123", + * metadata: { + * pp_stripe_stripe_customer_id: "str_1234" + * } + * }, + * }, + * }) + * ``` + * + */ + listPaymentMethods( + filters: FilterablePaymentMethodProps, + config: FindConfig, + sharedContext?: Context + ) + + /** + * This method retrieves all payment methods along with the total count of available payment methods, based on the context and configuration. + * + * @param {FilterablePaymentMethodProps} filters - The filters to apply on the retrieved payment methods. + * @param {FindConfig} config - The configurations determining how the payment method is retrieved. Its properties, such as `select` or `relations`, accept the + * attributes or relations associated with a payment method. + * @param {Context} sharedContext - A context used to share resources, such as transaction manager, between the application and the module. + * @returns {Promise<[PaymentMethodDTO[], number]>} The list of payment methods along with their total count. + * + * @example + * To retrieve a list of payment methods for a customer: + * + * ```ts + * const [paymentMethods, count] = + * await paymentModuleService.listAndCountPaymentMethods({ + * provider_id: "pp_stripe_stripe", + * context: { + * customer: { + * id: "cus_123", + * metadata: { + * pp_stripe_stripe_customer_id: "str_1234" + * } + * }, + * }, + * }) + * ``` + * + */ + listAndCountPaymentMethods( + filters: FilterablePaymentMethodProps, + config: FindConfig, + sharedContext?: Context + ): Promise<[PaymentMethodDTO[], number]> + /** * This method retrieves a paginated list of captures based on optional filters and configuration. * diff --git a/packages/core/utils/src/payment/abstract-payment-provider.ts b/packages/core/utils/src/payment/abstract-payment-provider.ts index 62e0fee02f828..75afa410fe4fb 100644 --- a/packages/core/utils/src/payment/abstract-payment-provider.ts +++ b/packages/core/utils/src/payment/abstract-payment-provider.ts @@ -9,6 +9,7 @@ import { ProviderWebhookPayload, UpdatePaymentProviderSession, WebhookActionResult, + PaymentAccountHolderResponse, } from "@medusajs/types" export abstract class AbstractPaymentProvider> @@ -625,6 +626,113 @@ export abstract class AbstractPaymentProvider> context: UpdatePaymentProviderSession ): Promise + /** + * Create an account holder in the third-party service. In many payment providers this is optional. + * + * @param input - The context for which the account holder is created. + * @returns An object whose `data` property is set to the data returned by the payment provider. This should typically be stored as part of + * your customer entity metadata + * + * @example + * // other imports... + * import { + * PaymentProviderContext, + * PaymentProviderError, + * PaymentMethodResponse, + * PaymentAccountHolderResponse, + * } from "@medusajs/framework/types" + * + * + * class MyPaymentProviderService extends AbstractPaymentProvider< + * Options + * > { + * async createAccountHolder( + * context: PaymentProviderContext + * ): Promise { + * const { + * email, + * customer, + * } = context + * const externalCustomerId = customer.metadata.pp_stripe_stripe_customer_id + * if(externalCustomerId) { + * return { data: {} } + * } + * + * try { + * // assuming you have a client that creates a customer + * const response = await this.client.createCustomer( + * {email: email ?? customer.email} + * ) + * + * return { data: { pp_stripe_stripe_customer_id: response.id } } + * } catch (e) { + * return { + * error: e, + * code: "unknown", + * detail: e + * } + * } + * } + * + * // ... + * } + */ + abstract createAccountHolder( + input: PaymentProviderContext + ): Promise + + /** + * Delete an account holder in the third-party service. In many payment providers this is optional. + * + * @param input - The context for which the account holder is created. + * @returns An object whose `data` property is set to the data returned by the payment provider. This should typically be stored as part of + * your customer entity metadata, and it would typically unset the account holder fields + * + * @example + * // other imports... + * import { + * PaymentProviderContext, + * PaymentProviderError, + * PaymentMethodResponse, + * PaymentAccountHolderResponse, + * } from "@medusajs/framework/types" + * + * + * class MyPaymentProviderService extends AbstractPaymentProvider< + * Options + * > { + * async deleteAccountHolder( + * context: PaymentProviderContext + * ): Promise { + * const { customer } = context + * const externalCustomerId = customer.metadata.pp_stripe_stripe_customer_id + * if(externalCustomerId) { + * return { data: {} } + * } + * + * try { + * // assuming you have a client that creates a customer + * const response = await this.client.deleteCustomer( + + * ) + * + * return { data: { pp_stripe_stripe_customer_id: "" } } + * } catch (e) { + * return { + * error: e, + * code: "unknown", + * detail: e + * } + * } + * } + * + * // ... + * } + */ + abstract deleteAccountHolder( + input: PaymentProviderContext + ): Promise + /** * List the payment methods associated with the context (eg. customer) of the payment provider, if any. * @@ -650,7 +758,7 @@ export abstract class AbstractPaymentProvider> * const { * customer, * } = context - * const externalCustomerId = customer.metadata.stripe_id + * const externalCustomerId = customer.metadata.pp_stripe_stripe_customer_id * * try { * // assuming you have a client that updates the payment 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 02eadea4a7249..9e0b0e19cb437 100644 --- a/packages/medusa/src/api/store/payment-collections/[id]/payment-sessions/route.ts +++ b/packages/medusa/src/api/store/payment-collections/[id]/payment-sessions/route.ts @@ -11,19 +11,12 @@ export const POST = async ( res: MedusaResponse ) => { const collectionId = req.params.id - const { context = {}, data, provider_id } = req.body + const { provider_id } = req.body - // If the customer is logged in, we auto-assign them to the payment collection - if (req.auth_context?.actor_id) { - ;(context as any).customer = { - id: req.auth_context?.actor_id, - } - } const workflowInput = { payment_collection_id: collectionId, provider_id: provider_id, - data, - context, + customer_id: req.auth_context?.actor_id, } await createPaymentSessionsWorkflow(req.scope).run({ diff --git a/packages/medusa/src/api/store/payment-collections/validators.ts b/packages/medusa/src/api/store/payment-collections/validators.ts index edc4dc993bb1a..1bc974a8399d8 100644 --- a/packages/medusa/src/api/store/payment-collections/validators.ts +++ b/packages/medusa/src/api/store/payment-collections/validators.ts @@ -12,8 +12,6 @@ export type StoreCreatePaymentSessionType = z.infer< export const StoreCreatePaymentSession = z .object({ provider_id: z.string(), - context: z.record(z.unknown()).optional(), - data: z.record(z.unknown()).optional(), }) .strict() diff --git a/packages/modules/payment/src/providers/system.ts b/packages/modules/payment/src/providers/system.ts index f5765140e7077..184d5332532c9 100644 --- a/packages/modules/payment/src/providers/system.ts +++ b/packages/modules/payment/src/providers/system.ts @@ -3,6 +3,7 @@ import { PaymentMethodResponse, PaymentProviderError, PaymentProviderSessionResponse, + PaymentAccountHolderResponse, ProviderWebhookPayload, WebhookActionResult, } from "@medusajs/framework/types" @@ -73,6 +74,18 @@ export class SystemProviderService extends AbstractPaymentProvider { return {} } + async createAccountHolder( + _ + ): Promise { + return { data: {} } + } + + async deleteAccountHolder( + _ + ): Promise { + return { data: {} } + } + async listPaymentMethods(_): Promise { return [] } diff --git a/packages/modules/payment/src/services/payment-module.ts b/packages/modules/payment/src/services/payment-module.ts index dc0f2f391d796..c51a200aec2ef 100644 --- a/packages/modules/payment/src/services/payment-module.ts +++ b/packages/modules/payment/src/services/payment-module.ts @@ -30,6 +30,8 @@ import { UpdatePaymentCollectionDTO, UpdatePaymentDTO, UpdatePaymentSessionDTO, + CreateAccountHolderDTO, + DeleteAccountHolderDTO, UpsertPaymentCollectionDTO, WebhookActionResult, } from "@medusajs/framework/types" @@ -908,6 +910,26 @@ export default class PaymentModuleService ] } + @InjectManager() + async createAccountHolder( + input: CreateAccountHolderDTO + ): Promise> { + return this.paymentProviderService_.createAccountHolder( + input.provider_id, + input.context + ) + } + + @InjectManager() + async deleteAccountHolder( + input: DeleteAccountHolderDTO + ): Promise> { + return this.paymentProviderService_.deleteAccountHolder( + input.provider_id, + input.context + ) + } + @InjectManager() async listPaymentMethods( filters: FilterablePaymentMethodProps, diff --git a/packages/modules/payment/src/services/payment-provider.ts b/packages/modules/payment/src/services/payment-provider.ts index fb75896092d33..eb578a539779b 100644 --- a/packages/modules/payment/src/services/payment-provider.ts +++ b/packages/modules/payment/src/services/payment-provider.ts @@ -4,6 +4,7 @@ import { DAL, IPaymentProvider, Logger, + PaymentAccountHolderResponse, PaymentMethodResponse, PaymentProviderAuthorizeResponse, PaymentProviderContext, @@ -73,7 +74,7 @@ Please make sure that the provider is registered in the container and it is conf async updateSession( providerId: string, sessionInput: UpdatePaymentProviderSession - ): Promise | undefined> { + ): Promise { const provider = this.retrieveProvider(providerId) const paymentResponse = await provider.updatePayment(sessionInput) @@ -82,7 +83,7 @@ Please make sure that the provider is registered in the container and it is conf this.throwPaymentProviderError(paymentResponse) } - return (paymentResponse as PaymentProviderSessionResponse)?.data + return (paymentResponse as PaymentProviderSessionResponse).data } async deleteSession(input: PaymentProviderDataInput): Promise { @@ -152,6 +153,32 @@ Please make sure that the provider is registered in the container and it is conf return res as Record } + async createAccountHolder( + providerId: string, + context: PaymentProviderContext + ): Promise> { + const provider = this.retrieveProvider(providerId) + const res = await provider.createAccountHolder(context) + if (isPaymentProviderError(res)) { + this.throwPaymentProviderError(res) + } + + return (res as PaymentAccountHolderResponse).data + } + + async deleteAccountHolder( + providerId: string, + context: PaymentProviderContext + ): Promise> { + const provider = this.retrieveProvider(providerId) + const res = await provider.deleteAccountHolder(context) + if (isPaymentProviderError(res)) { + this.throwPaymentProviderError(res) + } + + return (res as PaymentAccountHolderResponse).data + } + async listPaymentMethods( providerId: string, context: PaymentProviderContext 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 3db9cf86cdac6..b4193a83517d4 100644 --- a/packages/modules/providers/payment-stripe/src/core/stripe-base.ts +++ b/packages/modules/providers/payment-stripe/src/core/stripe-base.ts @@ -6,6 +6,7 @@ import { PaymentProviderContext, PaymentProviderError, PaymentProviderSessionResponse, + PaymentAccountHolderResponse, ProviderWebhookPayload, UpdatePaymentProviderSession, WebhookActionResult, @@ -120,7 +121,7 @@ abstract class StripeBase extends AbstractPaymentProvider { async initiatePayment( input: CreatePaymentProviderSession ): Promise { - const { email, extra, session_id, customer } = input.context + const { extra, session_id, customer } = input.context const { currency_code, amount } = input const additionalParameters = this.normalizePaymentIntentParameters(extra) @@ -132,23 +133,8 @@ abstract class StripeBase extends AbstractPaymentProvider { ...additionalParameters, } - if (customer?.metadata?.stripe_id) { - intentRequest.customer = customer.metadata.stripe_id as string - } else { - let stripeCustomer - try { - stripeCustomer = await this.stripe_.customers.create({ - email, - }) - } catch (e) { - return this.buildError( - "An error occurred in initiatePayment when creating a Stripe customer", - e - ) - } - - intentRequest.customer = stripeCustomer.id - } + intentRequest.customer = customer?.metadata + ?.pp_stripe_stripe_customer_id as string | undefined let sessionData try { @@ -164,14 +150,6 @@ abstract class StripeBase extends AbstractPaymentProvider { return { data: sessionData, - // TODO: REVISIT - // update_requests: customer?.metadata?.stripe_id - // ? undefined - // : { - // customer_metadata: { - // stripe_id: intentRequest.customer, - // }, - // }, } } @@ -272,36 +250,111 @@ abstract class StripeBase extends AbstractPaymentProvider { async updatePayment( input: UpdatePaymentProviderSession ): Promise { - const { context, data, currency_code, amount } = input + const { data, currency_code, amount } = input const amountNumeric = getSmallestUnit(amount, currency_code) + if (isPresent(amount) && data.amount === amountNumeric) { + return { data } + } - const stripeId = context.customer?.metadata?.stripe_id + try { + const id = data.id as string + const sessionData = (await this.stripe_.paymentIntents.update(id, { + amount: amountNumeric, + })) as unknown as PaymentProviderSessionResponse["data"] - if (stripeId !== data.customer) { - return await this.initiatePayment(input) - } else { - if (isPresent(amount) && data.amount === amountNumeric) { - return { data } - } + return { data: sessionData } + } catch (e) { + return this.buildError("An error occurred in updatePayment", e) + } + } + + async createAccountHolder( + input: PaymentProviderContext + ): Promise { + const { email, customer } = input + if (!customer) { + return this.buildError( + "No customer in context", + new Error("No customer provided while creating account holder") + ) + } + + if (customer.metadata?.pp_stripe_stripe_customer_id) { + return { data: {} } + } + + const defaultAddress = + customer.addresses?.find((a) => a.is_default_billing) ?? + customer.addresses?.[0] + + const shipping = defaultAddress + ? ({ + name: customer.company_name, + address: { + city: defaultAddress.city, + country: defaultAddress.country_code, + line1: defaultAddress.address_1, + line2: defaultAddress.address_2, + postal_code: defaultAddress.postal_code, + state: defaultAddress.province, + }, + } as Stripe.CustomerCreateParams.Shipping) + : undefined + + try { + const stripeCustomer = await this.stripe_.customers.create({ + email: email ?? customer.email, + name: customer.first_name + ? `${customer.first_name} ${customer.last_name ?? ""}`.trim() + : undefined, + phone: customer.phone as string | undefined, + shipping, + }) + + return { data: { pp_stripe_stripe_customer_id: stripeCustomer.id } } + } catch (e) { + return this.buildError( + "An error occurred in createAccountHolder when creating a Stripe customer", + e + ) + } + } - try { - const id = data.id as string - const sessionData = (await this.stripe_.paymentIntents.update(id, { - amount: amountNumeric, - })) as unknown as PaymentProviderSessionResponse["data"] + async deleteAccountHolder( + input: PaymentProviderContext + ): Promise { + const { customer } = input + if (!customer) { + return this.buildError( + "No customer in context", + new Error("No customer provided while deleting account holder") + ) + } + + if (!customer.metadata?.pp_stripe_stripe_customer_id) { + return { data: {} } + } - return { data: sessionData } - } catch (e) { - return this.buildError("An error occurred in updatePayment", e) + try { + await this.stripe_.customers.del( + customer.metadata?.pp_stripe_stripe_customer_id as string + ) + + return { + data: { + pp_stripe_stripe_customer_id: "", + }, } + } catch (e) { + return this.buildError("An error occurred in deleteAccountHolder", e) } } async listPaymentMethods( context: PaymentProviderContext ): Promise { - const customerId = context.customer?.metadata?.stripe_id + const customerId = context.customer?.metadata?.pp_stripe_stripe_customer_id if (!customerId) { return [] }