diff --git a/plugins/braintree-payment/README.md b/plugins/braintree-payment/README.md index 1774cb91..8a0c4a76 100644 --- a/plugins/braintree-payment/README.md +++ b/plugins/braintree-payment/README.md @@ -85,12 +85,17 @@ dependencies:[Modules.CACHE] - **savePaymentMethod**: Save payment methods for future use (default: `true`). - **autoCapture**: Automatically capture payments (default: `true`). - **allowRefundOnRefunded**: Allow refund attempts on already-refunded imported transactions (default: `false`). +- **customHttpAgent**: Optional pre-configured Node `http.Agent` or `https.Agent` used for all Braintree API requests. If set, this takes highest precedence. +- **proxyUrl**: Optional proxy URL (for example `http://user:pass@proxy.example.com:8080`). When provided, the provider will try to create an HTTPS proxy agent using `https-proxy-agent`. +- **httpAgent**: Optional HTTPS agent configuration object used to create a standard `https.Agent` when `customHttpAgent` and `proxyUrl` are not provided. - **logging**: When `true`, logs important operations (initiate, authorize, capture, refund, etc.) to the console for debugging (default: `false`). > **Note:** > - `autoCapture`: If set to `true`, payments are captured automatically after authorization. > - `savePaymentMethod`: If set to `true`, customer payment methods are saved for future use. > - `allowRefundOnRefunded`: If set to `true`, the imported payment provider will gracefully handle refund attempts on transactions that have already been refunded in Braintree. Instead of throwing an error, it will log a warning and record the refund locally only. This is useful when orders are imported and later refunded directly in Braintree. +> - HTTP agent precedence is: `customHttpAgent` -> `proxyUrl` -> `httpAgent`. +> - If `proxyUrl` is set, install `https-proxy-agent` in your project so proxy agent creation succeeds. ### 3D Secure Setup diff --git a/plugins/braintree-payment/package.json b/plugins/braintree-payment/package.json index b3212a91..465b1962 100644 --- a/plugins/braintree-payment/package.json +++ b/plugins/braintree-payment/package.json @@ -1,6 +1,6 @@ { "name": "@lambdacurry/medusa-payment-braintree", - "version": "0.1.0", + "version": "0.1.1", "description": "Braintree plugin for Medusa", "author": "Lambda Curry (https://lambdacurry.dev)", "license": "MIT", diff --git a/plugins/braintree-payment/src/providers/payment-braintree/src/core/braintree-base.ts b/plugins/braintree-payment/src/providers/payment-braintree/src/core/braintree-base.ts index a2ff5b47..c7a41bc8 100644 --- a/plugins/braintree-payment/src/providers/payment-braintree/src/core/braintree-base.ts +++ b/plugins/braintree-payment/src/providers/payment-braintree/src/core/braintree-base.ts @@ -45,9 +45,11 @@ import type { } from '@medusajs/types'; import type { Transaction, TransactionNotification, TransactionStatus } from 'braintree'; import Braintree from 'braintree'; +import http from 'http'; +import https from 'https'; import { z } from 'zod'; import { formatToTwoDecimalString } from '../../../../utils/format-amount'; -import type { BraintreeOptions, CustomFields } from '../types'; +import type { BraintreeOptions, CustomFields, HttpAgentConfig } from '../types'; export type BraintreeConstructorArgs = Record & { logger: Logger; @@ -256,6 +258,69 @@ class BraintreeBase extends AbstractPaymentProvider { return result.data as BraintreePaymentSessionData; } + private createHttpAgent(): http.Agent | https.Agent | undefined { + // Backward compatibility: if customHttpAgent is directly provided, use it + if (this.options_.customHttpAgent) { + return this.options_.customHttpAgent; + } + + // If proxy URL is provided, try to create a proxy agent + if (this.options_.proxyUrl) { + try { + // Try to use https-proxy-agent (most common for HTTPS proxies) + // eslint-disable-next-line @typescript-eslint/no-require-imports + const { HttpsProxyAgent } = require('https-proxy-agent'); + + // Create proxy agent with URL string + // Proxy URL format: http://[username:password@]proxy.example.com:8080 + return new HttpsProxyAgent(this.options_.proxyUrl); + } catch (error) { + const isRequireMissingProxyAgent = + error instanceof Error && /Cannot find module ['"]https-proxy-agent['"]/.test(error.message); + const isModuleNotFoundProxyAgent = + typeof error === 'object' && + error !== null && + ('code' in error ? (error as { code?: string }).code === 'MODULE_NOT_FOUND' : false) && + error instanceof Error && + error.message.includes('https-proxy-agent'); + + if (isRequireMissingProxyAgent || isModuleNotFoundProxyAgent) { + this.logger.warn( + 'https-proxy-agent package not found. Install it with: npm install https-proxy-agent. Falling back to regular agent.', + ); + // Fall through to create regular agent only when the module is missing + } else { + const message = error instanceof Error ? error.message : String(error); + this.logger.error(`Failed to initialize proxy agent from proxyUrl: ${message}`); + throw error; + } + } + } + + // If httpAgent config is provided, create a regular HTTPS agent + if (this.options_.httpAgent) { + const agentOptions: https.AgentOptions = { + keepAlive: this.options_.httpAgent.keepAlive ?? true, + keepAliveMsecs: this.options_.httpAgent.keepAliveMsecs ?? 1000, + maxSockets: this.options_.httpAgent.maxSockets, + maxFreeSockets: this.options_.httpAgent.maxFreeSockets, + timeout: this.options_.httpAgent.timeout, + rejectUnauthorized: this.options_.httpAgent.rejectUnauthorized ?? true, + }; + + // Remove undefined values + Object.keys(agentOptions).forEach((key) => { + if (agentOptions[key as keyof https.AgentOptions] === undefined) { + delete agentOptions[key as keyof https.AgentOptions]; + } + }); + + return new https.Agent(agentOptions); + } + + return undefined; + } + init(): void { const envKey = (this.options_.environment || 'sandbox').toLowerCase(); const envMap: Record = { @@ -266,15 +331,22 @@ class BraintreeBase extends AbstractPaymentProvider { }; const environment = envMap[envKey] ?? Braintree.Environment.Sandbox; - this.gateway = - this.gateway || - new Braintree.BraintreeGateway({ - environment, - merchantId: this.options_.merchantId!, - publicKey: this.options_.publicKey!, - privateKey: this.options_.privateKey!, - }); + const gatewayConfig: Braintree.GatewayConfig = { + environment, + merchantId: this.options_.merchantId!, + publicKey: this.options_.publicKey!, + privateKey: this.options_.privateKey!, + }; + // Create and add HTTP agent if configured + // Using 'as any' because TypeScript definitions don't include customHttpAgent, + // but the runtime Braintree library supports it + const httpAgent = this.createHttpAgent(); + if (httpAgent) { + (gatewayConfig as any).customHttpAgent = httpAgent; + } + + this.gateway = this.gateway || new Braintree.BraintreeGateway(gatewayConfig); this.logDebug(`Gateway initialized (environment: ${envKey})`); } @@ -313,6 +385,80 @@ class BraintreeBase extends AbstractPaymentProvider { ); } } + + if (isDefined(options.proxyUrl)) { + if (typeof options.proxyUrl !== 'string' || !options.proxyUrl.trim()) { + throw new MedusaError( + MedusaError.Types.INVALID_ARGUMENT, + 'Option "proxyUrl" must be a non-empty string in Braintree plugin', + ); + } + + try { + new URL(options.proxyUrl); + } catch { + throw new MedusaError( + MedusaError.Types.INVALID_ARGUMENT, + `Option "proxyUrl" must be a valid URL in Braintree plugin: "${options.proxyUrl}"`, + ); + } + } + + if (isDefined(options.httpAgent)) { + if (typeof options.httpAgent !== 'object' || options.httpAgent === null || Array.isArray(options.httpAgent)) { + throw new MedusaError( + MedusaError.Types.INVALID_ARGUMENT, + 'Option "httpAgent" must be an object in Braintree plugin', + ); + } + + const { keepAlive, keepAliveMsecs, maxSockets, maxFreeSockets, timeout, rejectUnauthorized } = options.httpAgent; + + const isNonNegativeNumber = (value: unknown): value is number => + typeof value === 'number' && !Number.isNaN(value) && Number.isFinite(value) && value >= 0; + + if (isDefined(keepAlive) && typeof keepAlive !== 'boolean') { + throw new MedusaError( + MedusaError.Types.INVALID_ARGUMENT, + 'Option "httpAgent.keepAlive" must be a boolean in Braintree plugin', + ); + } + + if (isDefined(keepAliveMsecs) && !isNonNegativeNumber(keepAliveMsecs)) { + throw new MedusaError( + MedusaError.Types.INVALID_ARGUMENT, + 'Option "httpAgent.keepAliveMsecs" must be a number greater than or equal to 0 in Braintree plugin', + ); + } + + if (isDefined(maxSockets) && !isNonNegativeNumber(maxSockets)) { + throw new MedusaError( + MedusaError.Types.INVALID_ARGUMENT, + 'Option "httpAgent.maxSockets" must be a number greater than or equal to 0 in Braintree plugin', + ); + } + + if (isDefined(maxFreeSockets) && !isNonNegativeNumber(maxFreeSockets)) { + throw new MedusaError( + MedusaError.Types.INVALID_ARGUMENT, + 'Option "httpAgent.maxFreeSockets" must be a number greater than or equal to 0 in Braintree plugin', + ); + } + + if (isDefined(timeout) && !isNonNegativeNumber(timeout)) { + throw new MedusaError( + MedusaError.Types.INVALID_ARGUMENT, + 'Option "httpAgent.timeout" must be a number greater than or equal to 0 in Braintree plugin', + ); + } + + if (isDefined(rejectUnauthorized) && typeof rejectUnauthorized !== 'boolean') { + throw new MedusaError( + MedusaError.Types.INVALID_ARGUMENT, + 'Option "httpAgent.rejectUnauthorized" must be a boolean in Braintree plugin', + ); + } + } } async capturePayment(input: CapturePaymentInput): Promise { diff --git a/plugins/braintree-payment/src/providers/payment-braintree/src/types/index.ts b/plugins/braintree-payment/src/providers/payment-braintree/src/types/index.ts index 899dc49c..bf6d5287 100644 --- a/plugins/braintree-payment/src/providers/payment-braintree/src/types/index.ts +++ b/plugins/braintree-payment/src/providers/payment-braintree/src/types/index.ts @@ -1,4 +1,15 @@ import type Braintree from 'braintree'; +import type { Agent } from 'http'; +import type { Agent as HttpsAgent } from 'https'; + +export interface HttpAgentConfig { + keepAlive?: boolean; + keepAliveMsecs?: number; + maxSockets?: number; + maxFreeSockets?: number; + timeout?: number; + rejectUnauthorized?: boolean; +} export interface BraintreeOptions extends Braintree.ClientGatewayConfig { defaultCurrencyCode?: string; @@ -11,6 +22,12 @@ export interface BraintreeOptions extends Braintree.ClientGatewayConfig { webhookSecret: string; autoCapture: boolean; allowRefundOnRefunded?: boolean; + /** Lowest precedence agent config, used to create a standard https.Agent. */ + httpAgent?: HttpAgentConfig; + /** Optional proxy URL used to create an HTTPS proxy agent. */ + proxyUrl?: string; + /** Highest precedence: pass a fully constructed Node HTTP(S) agent directly. */ + customHttpAgent?: Agent | HttpsAgent; /** When true, logs important operations to the console for debugging. */ logging?: boolean; }