From ef7b9b937562144d1f077a16ceb8279c84f3b6af Mon Sep 17 00:00:00 2001 From: Stevche Radevski Date: Wed, 29 Oct 2025 15:07:33 +0100 Subject: [PATCH] feat: Implement medusa payments provider (#13772) * feat: Implement medusa payments provider * chore: Improvements after testing * chore: Add typings to medusa payments * fix: Final changes to complete medusa payment provider * update package * fix: Final changes to complete medusa payment provider --------- Co-authored-by: adrien2p Co-authored-by: Oli Juhl <59018053+olivermrbl@users.noreply.github.com> --- .changeset/stupid-dogs-rhyme.md | 5 + .../core/types/src/common/config-module.ts | 16 +- packages/core/types/src/payment/provider.ts | 51 ++ .../common/__tests__/define-config.spec.ts | 41 + .../core/utils/src/common/define-config.ts | 24 +- .../__tests__/loaders/providers.spec.ts | 54 ++ packages/modules/payment/package.json | 3 + .../modules/payment/src/loaders/providers.ts | 43 +- .../modules/payment/src/providers/index.ts | 3 +- .../src/providers/payment-medusa/index.ts | 1 + .../services/medusa-payments.ts | 758 ++++++++++++++++++ .../providers/payment-medusa/types/index.ts | 22 + .../payment-medusa/types/medusa-payments.ts | 30 + .../utils/__tests__/get-smallest-unit.ts | 21 + .../payment-medusa/utils/get-smallest-unit.ts | 79 ++ .../modules/payment/src/providers/system.ts | 12 +- .../payment/src/services/payment-provider.ts | 17 + .../payment-stripe/src/core/stripe-base.ts | 10 +- yarn.lock | 15 + 19 files changed, 1182 insertions(+), 23 deletions(-) create mode 100644 .changeset/stupid-dogs-rhyme.md create mode 100644 packages/modules/payment/src/providers/payment-medusa/index.ts create mode 100644 packages/modules/payment/src/providers/payment-medusa/services/medusa-payments.ts create mode 100644 packages/modules/payment/src/providers/payment-medusa/types/index.ts create mode 100644 packages/modules/payment/src/providers/payment-medusa/types/medusa-payments.ts create mode 100644 packages/modules/payment/src/providers/payment-medusa/utils/__tests__/get-smallest-unit.ts create mode 100644 packages/modules/payment/src/providers/payment-medusa/utils/get-smallest-unit.ts diff --git a/.changeset/stupid-dogs-rhyme.md b/.changeset/stupid-dogs-rhyme.md new file mode 100644 index 0000000000..2a8612fc00 --- /dev/null +++ b/.changeset/stupid-dogs-rhyme.md @@ -0,0 +1,5 @@ +--- +"@medusajs/payment": patch +--- + +Add a medusa payments provider diff --git a/packages/core/types/src/common/config-module.ts b/packages/core/types/src/common/config-module.ts index f9d7616d91..7b499c2e4c 100644 --- a/packages/core/types/src/common/config-module.ts +++ b/packages/core/types/src/common/config-module.ts @@ -223,18 +223,26 @@ export type MedusaCloudOptions = { * The environment handle of the Medusa Cloud environment. */ environmentHandle?: string + /** + * The sandbox handle of the Medusa Cloud sandbox. + */ + sandboxHandle?: string /** * The API key used to access Medusa Cloud services. */ apiKey?: string + /** + * The webhook secret used to verify webhooks. + */ + webhookSecret?: string + /** + * The endpoint of the Medusa Cloud payment service. + */ + paymentsEndpoint?: string /** * The endpoint of the Medusa Cloud email service. */ emailsEndpoint?: string - /** - * The sandbox handle of the Medusa Cloud sandbox. - */ - sandboxHandle?: string } /** diff --git a/packages/core/types/src/payment/provider.ts b/packages/core/types/src/payment/provider.ts index d65dac3e15..d33b9330d7 100644 --- a/packages/core/types/src/payment/provider.ts +++ b/packages/core/types/src/payment/provider.ts @@ -160,6 +160,12 @@ export interface RetrievePaymentInput extends PaymentProviderInput {} */ export interface CancelPaymentInput extends PaymentProviderInput {} +export interface RetrieveAccountHolderInput extends PaymentProviderInput { + /** + * The ID of the account holder to retrieve. + */ + id: string +} /** * The data to create an account holder. */ @@ -289,6 +295,17 @@ export interface RetrievePaymentOutput extends PaymentProviderOutput {} */ export interface CancelPaymentOutput extends PaymentProviderOutput {} +/** + * The result of retrieving an account holder in the third-party payment provider. The `data` + * property is stored as-is in Medusa's account holder's `data` property. + */ +export interface RetrieveAccountHolderOutput extends PaymentProviderOutput { + /** + * The ID of the account holder in the payment provider. + */ + id: string +} + /** * 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. @@ -405,6 +422,40 @@ export interface IPaymentProvider { cancelPayment(data: CancelPaymentInput): Promise + /** + * This method is used when retrieving an account holder in Medusa, allowing you to retrieve + * the equivalent account in the third-party payment provider. 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. + * + * @param data - Input data including the details of the account holder to retrieve. + * @returns The retrieved account holder. If an error occurs, throw it. + * + * @since 2.11.0 + * + * @example + * import { MedusaError } from "@medusajs/framework/utils" + * + * class MyPaymentProviderService extends AbstractPaymentProvider< + * Options + * > { + * async retrieveAccountHolder({ id }: RetrieveAccountHolderInput) { + * + * // assuming you have a client that retrieves the account holder + * const providerAccountHolder = await this.client.retrieveAccountHolder({ + * id + * }) + * + * return { + * id: providerAccountHolder.id, + * data: providerAccountHolder as unknown as Record + * } + * } + */ + retrieveAccountHolder?( + data: RetrieveAccountHolderInput + ): Promise + /** * This method is used when creating an account holder in Medusa, allowing you to create * the equivalent account in the third-party payment provider. An account holder is useful to diff --git a/packages/core/utils/src/common/__tests__/define-config.spec.ts b/packages/core/utils/src/common/__tests__/define-config.spec.ts index e66ab087e1..3d51e191c8 100644 --- a/packages/core/utils/src/common/__tests__/define-config.spec.ts +++ b/packages/core/utils/src/common/__tests__/define-config.spec.ts @@ -2016,6 +2016,8 @@ describe("defineConfig", function () { process.env.MEDUSA_CLOUD_ENVIRONMENT_HANDLE = "test-environment" process.env.MEDUSA_CLOUD_API_KEY = "test-api-key" process.env.MEDUSA_CLOUD_EMAILS_ENDPOINT = "test-emails-endpoint" + process.env.MEDUSA_CLOUD_PAYMENTS_ENDPOINT = "test-payments-endpoint" + process.env.MEDUSA_CLOUD_WEBHOOK_SECRET = "test-webhook-secret" const config = defineConfig() process.env = { ...originalEnv } @@ -2112,6 +2114,15 @@ describe("defineConfig", function () { "resolve": "@medusajs/medusa/order", }, "payment": { + "options": { + "cloud": { + "api_key": "test-api-key", + "endpoint": "test-payments-endpoint", + "environment_handle": "test-environment", + "sandbox_handle": undefined, + "webhook_secret": "test-webhook-secret", + }, + }, "resolve": "@medusajs/medusa/payment", }, "pricing": { @@ -2165,7 +2176,9 @@ describe("defineConfig", function () { "apiKey": "test-api-key", "emailsEndpoint": "test-emails-endpoint", "environmentHandle": "test-environment", + "paymentsEndpoint": "test-payments-endpoint", "sandboxHandle": undefined, + "webhookSecret": "test-webhook-secret", }, "databaseUrl": "postgres://localhost/medusa-starter-default", "http": { @@ -2197,6 +2210,8 @@ describe("defineConfig", function () { process.env.MEDUSA_CLOUD_SANDBOX_HANDLE = "test-sandbox" process.env.MEDUSA_CLOUD_API_KEY = "test-api-key" process.env.MEDUSA_CLOUD_EMAILS_ENDPOINT = "test-emails-endpoint" + process.env.MEDUSA_CLOUD_PAYMENTS_ENDPOINT = "test-payments-endpoint" + process.env.MEDUSA_CLOUD_WEBHOOK_SECRET = "test-webhook-secret" const config = defineConfig() process.env = { ...originalEnv } @@ -2293,6 +2308,15 @@ describe("defineConfig", function () { "resolve": "@medusajs/medusa/order", }, "payment": { + "options": { + "cloud": { + "api_key": "test-api-key", + "endpoint": "test-payments-endpoint", + "environment_handle": undefined, + "sandbox_handle": "test-sandbox", + "webhook_secret": "test-webhook-secret", + }, + }, "resolve": "@medusajs/medusa/payment", }, "pricing": { @@ -2346,7 +2370,9 @@ describe("defineConfig", function () { "apiKey": "test-api-key", "emailsEndpoint": "test-emails-endpoint", "environmentHandle": undefined, + "paymentsEndpoint": "test-payments-endpoint", "sandboxHandle": "test-sandbox", + "webhookSecret": "test-webhook-secret", }, "databaseUrl": "postgres://localhost/medusa-starter-default", "http": { @@ -2378,13 +2404,17 @@ describe("defineConfig", function () { process.env.MEDUSA_CLOUD_ENVIRONMENT_HANDLE = "test-environment" process.env.MEDUSA_CLOUD_API_KEY = "test-api-key" process.env.MEDUSA_CLOUD_EMAILS_ENDPOINT = "test-emails-endpoint" + process.env.MEDUSA_CLOUD_PAYMENTS_ENDPOINT = "test-payments-endpoint" + process.env.MEDUSA_CLOUD_WEBHOOK_SECRET = "test-webhook-secret" const config = defineConfig({ projectConfig: { http: {} as any, cloud: { environmentHandle: "overriden-environment", apiKey: "overriden-api-key", + webhookSecret: "overriden-webhook-secret", emailsEndpoint: "overriden-emails-endpoint", + paymentsEndpoint: "overriden-payments-endpoint", }, }, }) @@ -2483,6 +2513,15 @@ describe("defineConfig", function () { "resolve": "@medusajs/medusa/order", }, "payment": { + "options": { + "cloud": { + "api_key": "overriden-api-key", + "endpoint": "overriden-payments-endpoint", + "environment_handle": "overriden-environment", + "sandbox_handle": undefined, + "webhook_secret": "overriden-webhook-secret", + }, + }, "resolve": "@medusajs/medusa/payment", }, "pricing": { @@ -2536,7 +2575,9 @@ describe("defineConfig", function () { "apiKey": "overriden-api-key", "emailsEndpoint": "overriden-emails-endpoint", "environmentHandle": "overriden-environment", + "paymentsEndpoint": "overriden-payments-endpoint", "sandboxHandle": undefined, + "webhookSecret": "overriden-webhook-secret", }, "databaseUrl": "postgres://localhost/medusa-starter-default", "http": { diff --git a/packages/core/utils/src/common/define-config.ts b/packages/core/utils/src/common/define-config.ts index 919d7f1f16..d5d90d99bf 100644 --- a/packages/core/utils/src/common/define-config.ts +++ b/packages/core/utils/src/common/define-config.ts @@ -365,19 +365,16 @@ function normalizeProjectConfig( projectConfig: InputConfig["projectConfig"], { isCloud }: { isCloud: boolean } ): ConfigModule["projectConfig"] { - const { - http, - redisOptions, - sessionOptions, - cloud, - ...restOfProjectConfig - } = projectConfig || {} + const { http, redisOptions, sessionOptions, cloud, ...restOfProjectConfig } = + projectConfig || {} const mergedCloudOptions: MedusaCloudOptions = { environmentHandle: process.env.MEDUSA_CLOUD_ENVIRONMENT_HANDLE, sandboxHandle: process.env.MEDUSA_CLOUD_SANDBOX_HANDLE, apiKey: process.env.MEDUSA_CLOUD_API_KEY, + webhookSecret: process.env.MEDUSA_CLOUD_WEBHOOK_SECRET, emailsEndpoint: process.env.MEDUSA_CLOUD_EMAILS_ENDPOINT, + paymentsEndpoint: process.env.MEDUSA_CLOUD_PAYMENTS_ENDPOINT, ...cloud, } const hasCloudOptions = Object.values(mergedCloudOptions).some( @@ -492,7 +489,18 @@ function applyCloudOptionsToModules( ...(module.options ?? {}), } break - // Will add payment module soon + case Modules.PAYMENT: + module.options = { + cloud: { + api_key: config.apiKey, + webhook_secret: config.webhookSecret, + endpoint: config.paymentsEndpoint, + environment_handle: config.environmentHandle, + sandbox_handle: config.sandboxHandle, + }, + ...(module.options ?? {}), + } + break default: break } diff --git a/packages/modules/payment/integration-tests/__tests__/loaders/providers.spec.ts b/packages/modules/payment/integration-tests/__tests__/loaders/providers.spec.ts index 3aef8fb412..8806d3762b 100644 --- a/packages/modules/payment/integration-tests/__tests__/loaders/providers.spec.ts +++ b/packages/modules/payment/integration-tests/__tests__/loaders/providers.spec.ts @@ -7,9 +7,50 @@ jest.setTimeout(30000) moduleIntegrationTestRunner({ moduleName: Modules.PAYMENT, + moduleOptions: { + cloud: { + api_key: "test", + environment_handle: "test", + webhook_secret: "test", + endpoint: "test", + }, + }, testSuite: ({ service }) => { describe("Payment Module Service", () => { describe("providers", () => { + it("should load the system and medusa payments providers by default", async () => { + const paymentProviders = await service.listPaymentProviders() + + expect(paymentProviders).toHaveLength(2) + expect(paymentProviders).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + id: "pp_system_default", + }), + expect.objectContaining({ + id: "pp_medusa-payments_default", + }), + ]) + ) + }) + + it("should create a payment session successfully", async () => { + const paymentCollection = await service.createPaymentCollections({ + currency_code: "USD", + amount: 200, + }) + + const paymentSession = await service.createPaymentSession( + paymentCollection.id, + { + provider_id: "pp_system_default", + amount: 200, + currency_code: "USD", + data: {}, + } + ) + }) + it("should load payment plugins", async () => { let error = await service .createPaymentCollections([ @@ -46,3 +87,16 @@ moduleIntegrationTestRunner({ }) }, }) + +moduleIntegrationTestRunner({ + moduleName: Modules.PAYMENT, + moduleOptions: {}, + testSuite: ({ service }) => + describe("providers", () => { + it("should not load the medusa payments provider if the cloud options are not provided", async () => { + const paymentProviders = await service.listPaymentProviders() + expect(paymentProviders).toHaveLength(1) + expect(paymentProviders[0].id).toBe("pp_system_default") + }) + }), +}) diff --git a/packages/modules/payment/package.json b/packages/modules/payment/package.json index 271a3f6a74..2009df3c27 100644 --- a/packages/modules/payment/package.json +++ b/packages/modules/payment/package.json @@ -47,5 +47,8 @@ }, "peerDependencies": { "@medusajs/framework": "2.11.1" + }, + "dependencies": { + "stripe": "19.1.0" } } diff --git a/packages/modules/payment/src/loaders/providers.ts b/packages/modules/payment/src/loaders/providers.ts index 035a96a825..8f4402a188 100644 --- a/packages/modules/payment/src/loaders/providers.ts +++ b/packages/modules/payment/src/loaders/providers.ts @@ -41,11 +41,46 @@ export default async ({ ( | ModulesSdkTypes.ModuleServiceInitializeOptions | ModulesSdkTypes.ModuleServiceInitializeCustomDataLayerOptions - ) & { providers: ModuleProvider[] } + ) & { + providers: ModuleProvider[] + cloud: { + api_key?: string + endpoint?: string + environment_handle?: string + sandbox_handle?: string + webhook_secret?: string + } + } >): Promise => { - // Local providers - for (const provider of Object.values(providers)) { - await registrationFn(provider, container, { id: "default" }) + await registrationFn(providers.SystemPaymentProvider, container, { + id: "default", + }) + + // We only want to register medusa payments if the options for it have been provided. + const { + api_key, + endpoint, + environment_handle, + sandbox_handle, + webhook_secret, + } = options?.cloud ?? {} + + if ( + api_key && + endpoint && + webhook_secret && + (environment_handle || sandbox_handle) + ) { + await registrationFn(providers.MedusaPaymentsProvider, container, { + options: { + api_key, + endpoint, + environment_handle, + sandbox_handle, + webhook_secret, + }, + id: "default", + }) } await moduleProviderLoader({ diff --git a/packages/modules/payment/src/providers/index.ts b/packages/modules/payment/src/providers/index.ts index 79d8db4231..f7996a59af 100644 --- a/packages/modules/payment/src/providers/index.ts +++ b/packages/modules/payment/src/providers/index.ts @@ -1 +1,2 @@ -export { default as SystemPaymentProvider } from "./system" +export { SystemPaymentProvider } from "./system" +export { MedusaPaymentsProvider } from "./payment-medusa" diff --git a/packages/modules/payment/src/providers/payment-medusa/index.ts b/packages/modules/payment/src/providers/payment-medusa/index.ts new file mode 100644 index 0000000000..3098fafdcc --- /dev/null +++ b/packages/modules/payment/src/providers/payment-medusa/index.ts @@ -0,0 +1 @@ +export { MedusaPaymentsProvider } from "./services/medusa-payments" diff --git a/packages/modules/payment/src/providers/payment-medusa/services/medusa-payments.ts b/packages/modules/payment/src/providers/payment-medusa/services/medusa-payments.ts new file mode 100644 index 0000000000..a360efafb2 --- /dev/null +++ b/packages/modules/payment/src/providers/payment-medusa/services/medusa-payments.ts @@ -0,0 +1,758 @@ +import { setTimeout } from "timers/promises" +import stripe from "stripe" +import { + AuthorizePaymentInput, + AuthorizePaymentOutput, + CancelPaymentInput, + CancelPaymentOutput, + CapturePaymentInput, + CapturePaymentOutput, + RetrieveAccountHolderInput, + RetrieveAccountHolderOutput, + CreateAccountHolderInput, + CreateAccountHolderOutput, + DeleteAccountHolderInput, + DeleteAccountHolderOutput, + DeletePaymentInput, + DeletePaymentOutput, + GetPaymentStatusInput, + GetPaymentStatusOutput, + InitiatePaymentInput, + InitiatePaymentOutput, + ListPaymentMethodsInput, + ListPaymentMethodsOutput, + ProviderWebhookPayload, + RefundPaymentInput, + RefundPaymentOutput, + RetrievePaymentInput, + RetrievePaymentOutput, + SavePaymentMethodInput, + SavePaymentMethodOutput, + UpdateAccountHolderInput, + UpdateAccountHolderOutput, + UpdatePaymentInput, + UpdatePaymentOutput, + WebhookActionResult, +} from "@medusajs/framework/types" +import { + AbstractPaymentProvider, + isDefined, + isPresent, + PaymentActions, + PaymentSessionStatus, +} from "@medusajs/framework/utils" +import { MedusaPaymentsOptions } from "../types" +import { + getAmountFromSmallestUnit, + getSmallestUnit, +} from "../utils/get-smallest-unit" +import { + CreateAccountHolderRequest, + CreatePaymentRequest, + MedusaAccountHolder, + MedusaPayment, + MedusaPaymentMethod, + MedusaPaymentMethodSession, + MedusaRefund, + RefundPaymentRequest, + UpdateAccountHolderRequest, +} from "../types/medusa-payments" + +type HandledErrorType = { retry: true } | { retry: false; data: any } +class CloudServiceError extends Error { + type: string + originalType: string + data: any + message: string + + constructor(type: string, originalType: string, data: any, message: string) { + super(message) + this.type = type + this.originalType = originalType + this.data = data + this.message = message + } +} + +export class MedusaPaymentsProvider extends AbstractPaymentProvider { + static identifier = "medusa-payments" + protected readonly options_: MedusaPaymentsOptions + protected container_: Record + // The stripe client is used to construct the webhook event, since we construct it the same way as Stripe does. + protected readonly stripeClient: stripe + + // The provider is loaded in a different a bit differently - it is not passed as a provider but the options are passed to the module's configuration. + // Due to that, the validation needs to happen in the constructor + static validateOptions(options: MedusaPaymentsOptions): void { + return validateOptions(options) + } + + constructor(cradle: Record, options: MedusaPaymentsOptions) { + super(cradle, options) + + validateOptions(options ?? {}) + this.options_ = options + this.stripeClient = new stripe(options.api_key) + } + + request( + url: string, + options: Omit & { body?: object } + ): Promise { + const headers = { + "Content-Type": "application/json", + Authorization: `Basic ${this.options_.api_key}`, + } + if (this.options_.environment_handle) { + headers["x-medusa-environment-handle"] = this.options_.environment_handle + } + if (this.options_.sandbox_handle) { + headers["x-medusa-sandbox-handle"] = this.options_.sandbox_handle + } + + return fetch(`${this.options_.endpoint}${url}`, { + ...options, + body: options.body ? JSON.stringify(options.body) : undefined, + headers: { + ...options.headers, + ...headers, + }, + }).then(async (res) => { + const body = await res.json().catch(() => ({})) + + if (!res.ok) { + throw new CloudServiceError( + body.type, + body.originalType, + body.data, + body.message + ) + } + + return body + }) + } + + normalizePaymentParameters( + extra?: Record + ): Partial { + const res = { + description: extra?.payment_description ?? "", + capture_method: extra?.capture_method as "automatic" | "manual", + setup_future_usage: extra?.setup_future_usage, + payment_method_types: extra?.payment_method_types, + payment_method_data: extra?.payment_method_data, + payment_method_options: extra?.payment_method_options, + automatic_payment_methods: extra?.automatic_payment_methods, + off_session: extra?.off_session, + confirm: extra?.confirm, + payment_method: extra?.payment_method, + return_url: extra?.return_url, + shared_payment_token: extra?.shared_payment_token, + } as Partial + + return res + } + + handleStripeError(error: CloudServiceError): HandledErrorType { + switch (error.type) { + case "MedusaCardError": + // Medusa has created a payment but it failed + // Extract and return payment object to be stored in payment_session + // Allows for reference to the failed intent and potential webhook reconciliation + const medusaPayment = error.data as MedusaPayment | undefined + if (medusaPayment) { + return { + retry: false, + data: medusaPayment, + } + } else { + throw error + } + + case "MedusaConnectionError": + case "MedusaRateLimitError": + // Connection or rate limit errors indicate an uncertain result + // Retry the operation + return { + retry: true, + } + case "MedusaAPIError": { + // API errors should be treated as indeterminate per Stripe documentation + // Rely on webhooks rather than assuming failure + return { + retry: false, + data: { + indeterminate_due_to: "medusa_api_error", + }, + } + } + default: + throw error + } + } + + async executeWithRetry( + apiCall: () => Promise, + maxRetries: number = 3, + baseDelay: number = 1000, + currentAttempt: number = 1 + ): Promise { + try { + return await apiCall() + } catch (error) { + const handledError = this.handleStripeError(error) + + if (!handledError.retry) { + // If retry is false, we know data exists per the type definition + return handledError.data + } + + if (handledError.retry && currentAttempt <= maxRetries) { + // Logic for retrying + const delay = + baseDelay * + Math.pow(2, currentAttempt - 1) * + (0.5 + Math.random() * 0.5) + await setTimeout(delay) + return this.executeWithRetry( + apiCall, + maxRetries, + baseDelay, + currentAttempt + 1 + ) + } + + // Retries are exhausted + throw error + } + } + + async getPaymentStatus( + input: GetPaymentStatusInput + ): Promise { + const id = input?.data?.id as string + if (!id) { + throw new Error( + "No payment intent ID provided while getting payment status" + ) + } + + const payment = await this.retrievePayment({ data: { id } }) + + return { + status: payment.data?.status as PaymentSessionStatus, + data: payment.data, + } + } + + async initiatePayment({ + currency_code, + amount, + data, + context, + }: InitiatePaymentInput): Promise { + const additionalParameters = this.normalizePaymentParameters( + data as Record + ) + + const intentRequest = { + amount: getSmallestUnit(amount, currency_code), + currency: currency_code, + metadata: { + session_id: data?.session_id as string, + }, + account_holder_id: context?.account_holder?.data?.id as + | string + | undefined, + idempotency_key: context?.idempotency_key, + ...additionalParameters, + } as CreatePaymentRequest + + const payment = (await this.executeWithRetry(() => { + return this.request<{ payment: any }>("/payments", { + method: "POST", + body: intentRequest, + }).then((data) => data.payment) + })) as MedusaPayment + + return { + id: payment.id, + ...this.getStatus(payment), + } + } + + async authorizePayment( + input: AuthorizePaymentInput + ): Promise { + return this.getPaymentStatus(input) + } + + async cancelPayment({ + data, + context, + }: CancelPaymentInput): Promise { + const id = data?.id as string + + if (!id) { + return { data: data } + } + + const intent = (await this.executeWithRetry(() => { + return this.request<{ payment: any }>(`/payments/${id}/cancel`, { + method: "POST", + body: { + idempotency_key: context?.idempotency_key, + }, + }).then((data) => data.payment) + })) as MedusaPayment + + return { data: intent as unknown as Record } + } + + async capturePayment({ + data, + context, + }: CapturePaymentInput): Promise { + const id = data?.id as string + + const intent = (await this.executeWithRetry(() => { + return this.request<{ payment: any }>(`/payments/${id}/capture`, { + method: "POST", + body: { + idempotency_key: context?.idempotency_key, + }, + }).then((data) => data.payment) + })) as MedusaPayment + + return { data: intent as unknown as Record } + } + + async deletePayment(input: DeletePaymentInput): Promise { + return await this.cancelPayment(input) + } + + async refundPayment({ + amount, + data, + context, + }: RefundPaymentInput): Promise { + const id = data?.id as string + if (!id) { + throw new Error("No payment intent ID provided while refunding payment") + } + + const currencyCode = data?.currency as string + + const response = (await this.executeWithRetry(() => { + return this.request<{ refund: any }>(`/payments/${id}/refund`, { + method: "POST", + body: { + amount: getSmallestUnit(amount, currencyCode), + idempotency_key: context?.idempotency_key, + } as RefundPaymentRequest, + }).then((data) => data.refund) + })) as MedusaRefund + + return { data: response as unknown as Record } + } + + async retrievePayment({ + data, + }: RetrievePaymentInput): Promise { + const id = data?.id as string + + const intent = (await this.executeWithRetry(() => { + return this.request<{ payment: any }>(`/payments/${id}`, { + method: "GET", + }).then((data) => data.payment) + })) as MedusaPayment + + intent.amount = getAmountFromSmallestUnit(intent.amount, intent.currency) + + return { data: intent as unknown as Record } + } + + async updatePayment({ + data, + currency_code, + amount, + context, + }: UpdatePaymentInput): Promise { + const amountNumeric = getSmallestUnit(amount, currency_code) + if (isPresent(amount) && data?.amount === amountNumeric) { + return this.getStatus( + data as unknown as MedusaPayment + ) as unknown as UpdatePaymentOutput + } + + const id = data?.id as string + + const sessionData = (await this.executeWithRetry(() => { + return this.request<{ payment: any }>(`/payments/${id}`, { + method: "POST", + body: { + amount: amountNumeric, + idempotency_key: context?.idempotency_key, + }, + }).then((data) => data.payment) + })) as MedusaPayment + + return this.getStatus(sessionData) + } + + async retrieveAccountHolder({ + id, + }: RetrieveAccountHolderInput): Promise { + if (!id) { + throw new Error( + "No account holder ID provided while getting account holder" + ) + } + + const res = (await this.executeWithRetry(() => { + return this.request<{ account_holder: any }>(`/account-holders/${id}`, { + method: "GET", + }).then((data) => data.account_holder) + })) as MedusaAccountHolder + + return { + id: res.id, + data: res as unknown as Record, + } + } + + async createAccountHolder({ + context, + }: CreateAccountHolderInput): Promise { + const { account_holder, customer, idempotency_key } = context + + if (account_holder?.data?.id) { + return { id: account_holder.data.id as string } + } + + if (!customer) { + throw new Error("No customer provided while creating account holder") + } + + 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, + }, + } + : undefined + + const accountHolder = (await this.executeWithRetry(() => { + return this.request<{ account_holder: any }>(`/account-holders`, { + method: "POST", + body: { + email: customer.email, + name: + customer.company_name || + `${customer.first_name ?? ""} ${customer.last_name ?? ""}`.trim() || + undefined, + phone: customer.phone as string | undefined, + ...shipping, + idempotency_key: idempotency_key, + } as CreateAccountHolderRequest, + }).then((data) => data.account_holder) + })) as MedusaAccountHolder + + return { + id: accountHolder.id, + data: accountHolder as unknown as Record, + } + } + + async updateAccountHolder({ + context, + }: UpdateAccountHolderInput): Promise { + const { account_holder, customer, idempotency_key } = context + + if (!account_holder?.data?.id) { + throw new Error( + "No account holder in context 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, + }, + } + : undefined + + const accountHolder = (await this.executeWithRetry(() => { + return this.request<{ account_holder: any }>( + `/account-holders/${accountHolderId}`, + { + method: "POST", + body: { + email: customer.email, + name: + customer.company_name || + `${customer.first_name ?? ""} ${ + customer.last_name ?? "" + }`.trim() || + undefined, + phone: customer.phone as string | undefined, + ...shipping, + idempotency_key: idempotency_key, + } as UpdateAccountHolderRequest, + } + ).then((data) => data.account_holder) + })) as MedusaAccountHolder + + return { + data: accountHolder as unknown as Record, + } + } + + async deleteAccountHolder({ + context, + }: DeleteAccountHolderInput): Promise { + const { account_holder } = context + const accountHolderId = account_holder?.data?.id as string | undefined + if (!accountHolderId) { + throw new Error( + "No account holder in context while deleting account holder" + ) + } + + await this.executeWithRetry(() => { + return this.request<{ account_holder: any }>( + `/account-holders/${accountHolderId}`, + { + method: "DELETE", + } + ) + }) + + return {} + } + + async listPaymentMethods({ + context, + }: ListPaymentMethodsInput): Promise { + const accountHolderId = context?.account_holder?.data?.id as + | string + | undefined + if (!accountHolderId) { + return [] + } + + const paymentMethods = (await this.executeWithRetry(() => { + return this.request<{ payment_methods: any[] }>( + `/payment-methods?account_holder_id=${accountHolderId}`, + { + method: "GET", + } + ).then((data) => data.payment_methods) + })) as MedusaPaymentMethod[] + + return paymentMethods.map((method) => ({ + id: method.id, + data: method as unknown as Record, + })) + } + + async savePaymentMethod({ + context, + data, + }: SavePaymentMethodInput): Promise { + const accountHolderId = context?.account_holder?.data?.id as + | string + | undefined + + if (!accountHolderId) { + throw new Error("Account holder not set while saving a payment method") + } + + const paymentMethodSession = (await this.executeWithRetry(() => { + return this.request<{ payment_method_session: any }>(`/payment-methods`, { + method: "POST", + body: { + account_holder_id: accountHolderId, + ...data, + idempotency_key: context?.idempotency_key, + }, + }).then((data) => data.payment_method_session) + })) as MedusaPaymentMethodSession + + return { + id: paymentMethodSession.id, + data: paymentMethodSession as unknown as Record, + } + } + + private getStatus(payment: MedusaPayment) { + const paymenAsRecord = payment as unknown as Record + + switch (payment.status) { + case "requires_payment_method": + if (payment.last_payment_error) { + return { status: PaymentSessionStatus.ERROR, data: paymenAsRecord } + } + return { status: PaymentSessionStatus.PENDING, data: paymenAsRecord } + case "requires_confirmation": + case "processing": + return { status: PaymentSessionStatus.PENDING, data: paymenAsRecord } + case "requires_action": + return { + status: PaymentSessionStatus.REQUIRES_MORE, + data: paymenAsRecord, + } + case "canceled": + return { status: PaymentSessionStatus.CANCELED, data: paymenAsRecord } + case "requires_capture": + return { status: PaymentSessionStatus.AUTHORIZED, data: paymenAsRecord } + case "succeeded": + return { status: PaymentSessionStatus.CAPTURED, data: paymenAsRecord } + default: + return { status: PaymentSessionStatus.PENDING, data: paymenAsRecord } + } + } + + 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.created": + case "payment_intent.processing": + return { + action: PaymentActions.PENDING, + data: { + session_id: intent.metadata.session_id, + amount: getAmountFromSmallestUnit(intent.amount, currency), + }, + } + case "payment_intent.canceled": + return { + action: PaymentActions.CANCELED, + data: { + session_id: intent.metadata.session_id, + amount: getAmountFromSmallestUnit(intent.amount, currency), + }, + } + case "payment_intent.payment_failed": + return { + action: PaymentActions.FAILED, + data: { + session_id: intent.metadata.session_id, + 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.partially_funded": + return { + action: PaymentActions.REQUIRES_MORE, + data: { + session_id: intent.metadata.session_id, + amount: getAmountFromSmallestUnit( + intent.next_action?.display_bank_transfer_instructions + ?.amount_remaining ?? intent.amount, + 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 } + } + } + + /** + * Constructs Medusa Payments Webhook event + * @param {object} data - the data of the webhook request: req.body + * ensures integrity of the webhook event + * @return {object} Medusa Payments Webhook event + */ + constructWebhookEvent(data: ProviderWebhookPayload["payload"]) { + const signature = data.headers["medusa-payments-signature"] as string + + const stripeEvent = this.stripeClient.webhooks.constructEvent( + data.rawData as string | Buffer, + signature, + this.options_.webhook_secret + ) + + return stripeEvent + } +} + +const validateOptions = (options: MedusaPaymentsOptions): void => { + if (!isDefined(options.endpoint)) { + throw new Error( + "Required option `endpoint` is missing in Medusa payments plugin" + ) + } + if (!isDefined(options.webhook_secret)) { + throw new Error( + "Required option `webhook_secret` is missing in Medusa payments plugin" + ) + } + if (!isDefined(options.api_key)) { + throw new Error( + "Required option `api_key` is missing in Medusa payments plugin" + ) + } + + if ( + !isDefined(options.environment_handle) && + !isDefined(options.sandbox_handle) + ) { + throw new Error( + "Required option `environment_handle` or `sandbox_handle` is missing in Medusa payments plugin" + ) + } +} diff --git a/packages/modules/payment/src/providers/payment-medusa/types/index.ts b/packages/modules/payment/src/providers/payment-medusa/types/index.ts new file mode 100644 index 0000000000..d6109d90d2 --- /dev/null +++ b/packages/modules/payment/src/providers/payment-medusa/types/index.ts @@ -0,0 +1,22 @@ +export interface MedusaPaymentsOptions { + /** + * The API key for the Stripe account + */ + api_key: string + /** + * The webhook secret used to verify webhooks + */ + webhook_secret: string + /** + * The endpoint to use for the payments + */ + endpoint: string + /** + * The handle of the cloud environment + */ + environment_handle: string + /** + * The handle of the cloud sandbox + */ + sandbox_handle: string +} diff --git a/packages/modules/payment/src/providers/payment-medusa/types/medusa-payments.ts b/packages/modules/payment/src/providers/payment-medusa/types/medusa-payments.ts new file mode 100644 index 0000000000..894895d1e1 --- /dev/null +++ b/packages/modules/payment/src/providers/payment-medusa/types/medusa-payments.ts @@ -0,0 +1,30 @@ +import stripe from "stripe" + +export interface CreatePaymentRequest extends stripe.PaymentIntentCreateParams { + account_holder_id?: string + idempotency_key?: string +} + +export interface MedusaPayment extends stripe.PaymentIntent { + account_holder_id?: string +} + +export interface RefundPaymentRequest extends stripe.RefundCreateParams { + idempotency_key?: string +} + +export interface MedusaRefund extends stripe.Refund {} + +export interface CreateAccountHolderRequest + extends stripe.CustomerCreateParams {} + +export interface UpdateAccountHolderRequest + extends stripe.CustomerUpdateParams {} + +export interface MedusaAccountHolder extends stripe.Customer {} + +export interface MedusaPaymentMethod extends stripe.PaymentMethod { + account_holder_id?: string +} + +export interface MedusaPaymentMethodSession extends stripe.SetupIntent {} diff --git a/packages/modules/payment/src/providers/payment-medusa/utils/__tests__/get-smallest-unit.ts b/packages/modules/payment/src/providers/payment-medusa/utils/__tests__/get-smallest-unit.ts new file mode 100644 index 0000000000..b193cb8e9c --- /dev/null +++ b/packages/modules/payment/src/providers/payment-medusa/utils/__tests__/get-smallest-unit.ts @@ -0,0 +1,21 @@ +import { getSmallestUnit } from "../get-smallest-unit" + +describe("getSmallestUnit", () => { + it("should convert an amount to the format required by Stripe based on currency", () => { + // 0 decimals + expect(getSmallestUnit(50098, "JPY")).toBe(50098) + + // 3 decimals + expect(getSmallestUnit(5.124, "KWD")).toBe(5130) + + // 2 decimals + expect(getSmallestUnit(2.675, "USD")).toBe(268) + + expect(getSmallestUnit(100.54, "USD")).toBe(10054) + expect(getSmallestUnit(5.126, "KWD")).toBe(5130) + expect(getSmallestUnit(0.54, "USD")).toBe(54) + expect(getSmallestUnit(0.054, "USD")).toBe(5) + expect(getSmallestUnit(0.005104, "USD")).toBe(1) + expect(getSmallestUnit(0.004104, "USD")).toBe(0) + }) +}) diff --git a/packages/modules/payment/src/providers/payment-medusa/utils/get-smallest-unit.ts b/packages/modules/payment/src/providers/payment-medusa/utils/get-smallest-unit.ts new file mode 100644 index 0000000000..c007350921 --- /dev/null +++ b/packages/modules/payment/src/providers/payment-medusa/utils/get-smallest-unit.ts @@ -0,0 +1,79 @@ +import { BigNumberInput } from "@medusajs/framework/types" +import { BigNumber, MathBN } from "@medusajs/framework/utils" + +function getCurrencyMultiplier(currency) { + const currencyMultipliers = { + 0: [ + "BIF", + "CLP", + "DJF", + "GNF", + "JPY", + "KMF", + "KRW", + "MGA", + "PYG", + "RWF", + "UGX", + "VND", + "VUV", + "XAF", + "XOF", + "XPF", + ], + 3: ["BHD", "IQD", "JOD", "KWD", "OMR", "TND"], + } + + currency = currency.toUpperCase() + let power = 2 + for (const [key, value] of Object.entries(currencyMultipliers)) { + if (value.includes(currency)) { + power = parseInt(key, 10) + break + } + } + return Math.pow(10, power) +} + +/** + * Converts an amount to the format required by Stripe based on currency. + * https://docs.stripe.com/currencies + * @param {BigNumberInput} amount - The amount to be converted. + * @param {string} currency - The currency code (e.g., 'USD', 'JOD'). + * @returns {number} - The converted amount in the smallest currency unit. + */ +export function getSmallestUnit( + amount: BigNumberInput, + currency: string +): number { + const multiplier = getCurrencyMultiplier(currency) + + let amount_ = + Math.round(new BigNumber(MathBN.mult(amount, multiplier)).numeric) / + multiplier + + const smallestAmount = new BigNumber(MathBN.mult(amount_, multiplier)) + + let numeric = smallestAmount.numeric + // Check if the currency requires rounding to the nearest ten + if (multiplier === 1e3) { + numeric = Math.ceil(numeric / 10) * 10 + } + + return parseInt(numeric.toString().split(".").shift()!, 10) +} + +/** + * Converts an amount from the smallest currency unit to the standard unit based on currency. + * @param {BigNumberInput} amount - The amount in the smallest currency unit. + * @param {string} currency - The currency code (e.g., 'USD', 'JOD'). + * @returns {number} - The converted amount in the standard currency unit. + */ +export function getAmountFromSmallestUnit( + amount: BigNumberInput, + currency: string +): number { + const multiplier = getCurrencyMultiplier(currency) + const standardAmount = new BigNumber(MathBN.div(amount, multiplier)) + return standardAmount.numeric +} diff --git a/packages/modules/payment/src/providers/system.ts b/packages/modules/payment/src/providers/system.ts index 45ebd0a0c1..4b5ceb943b 100644 --- a/packages/modules/payment/src/providers/system.ts +++ b/packages/modules/payment/src/providers/system.ts @@ -7,6 +7,8 @@ import { CancelPaymentOutput, CapturePaymentInput, CapturePaymentOutput, + RetrieveAccountHolderInput, + RetrieveAccountHolderOutput, CreateAccountHolderInput, CreateAccountHolderOutput, DeleteAccountHolderInput, @@ -32,7 +34,7 @@ import { PaymentSessionStatus, } from "@medusajs/framework/utils" -export class SystemProviderService extends AbstractPaymentProvider { +export class SystemPaymentProvider extends AbstractPaymentProvider { static identifier = "system" async getStatus(_): Promise { @@ -81,6 +83,12 @@ export class SystemProviderService extends AbstractPaymentProvider { return { data: {} } } + async retrieveAccountHolder( + input: RetrieveAccountHolderInput + ): Promise { + return { id: input.id } + } + async createAccountHolder( input: CreateAccountHolderInput ): Promise { @@ -108,4 +116,4 @@ export class SystemProviderService extends AbstractPaymentProvider { } } -export default SystemProviderService +export default SystemPaymentProvider diff --git a/packages/modules/payment/src/services/payment-provider.ts b/packages/modules/payment/src/services/payment-provider.ts index 48402ddcb9..8a516c9434 100644 --- a/packages/modules/payment/src/services/payment-provider.ts +++ b/packages/modules/payment/src/services/payment-provider.ts @@ -5,6 +5,8 @@ import { CancelPaymentOutput, CapturePaymentInput, CapturePaymentOutput, + RetrieveAccountHolderInput, + RetrieveAccountHolderOutput, CreateAccountHolderInput, CreateAccountHolderOutput, DAL, @@ -140,6 +142,21 @@ Please make sure that the provider is registered in the container and it is conf return await provider.refundPayment(input) } + async retrieveAccountHolder( + providerId: string, + input: RetrieveAccountHolderInput + ): Promise { + const provider = this.retrieveProvider(providerId) + if (!provider.retrieveAccountHolder) { + this.#logger.warn( + `Provider ${providerId} does not support retrieving account holders` + ) + return {} as unknown as RetrieveAccountHolderOutput + } + + return await provider.retrieveAccountHolder(input) + } + async createAccountHolder( providerId: string, input: CreateAccountHolderInput 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 510286b6f9..5d31e5c487 100644 --- a/packages/modules/providers/payment-stripe/src/core/stripe-base.ts +++ b/packages/modules/providers/payment-stripe/src/core/stripe-base.ts @@ -53,6 +53,7 @@ import { type StripeIndeterminateState = { indeterminate_due_to: string } + type StripeErrorData = Stripe.PaymentIntent | StripeIndeterminateState type HandledErrorType = | { retry: true } @@ -128,9 +129,7 @@ abstract class StripeBase extends AbstractPaymentProvider { res.return_url = extra?.return_url as string | undefined // @ts-expect-error - Need to update Stripe SDK - res.shared_payment_token = extra?.shared_payment_token as - | string - | undefined + res.shared_payment_token = extra?.shared_payment_token as string | undefined return res } @@ -248,7 +247,10 @@ abstract class StripeBase extends AbstractPaymentProvider { const intentRequest: Stripe.PaymentIntentCreateParams = { amount: getSmallestUnit(amount, currency_code), currency: currency_code, - metadata: { ...(data?.metadata ?? {}), session_id: data?.session_id as string }, + metadata: { + ...(data?.metadata ?? {}), + session_id: data?.session_id as string, + }, ...additionalParameters, } diff --git a/yarn.lock b/yarn.lock index d2b658ddda..162b841254 100644 --- a/yarn.lock +++ b/yarn.lock @@ -7114,6 +7114,7 @@ __metadata: "@swc/jest": ^0.2.36 jest: ^29.7.0 rimraf: ^3.0.2 + stripe: 19.1.0 tsc-alias: ^1.8.6 typescript: ^5.6.2 peerDependencies: @@ -33939,6 +33940,20 @@ __metadata: languageName: node linkType: hard +"stripe@npm:19.1.0": + version: 19.1.0 + resolution: "stripe@npm:19.1.0" + dependencies: + qs: ^6.11.0 + peerDependencies: + "@types/node": ">=16" + peerDependenciesMeta: + "@types/node": + optional: true + checksum: d1f6412373962a585f877af6b5933a000573dad1b0c5fe3937633ccb7117764ad46c8fb565431d846ac08f32811c150ffdce3a273717905a983e416ea131091f + languageName: node + linkType: hard + "stripe@npm:^15.5.0": version: 15.12.0 resolution: "stripe@npm:15.12.0"