diff --git a/plugins/braintree-payment/README.md b/plugins/braintree-payment/README.md index 14e9fbcd..550cff9c 100644 --- a/plugins/braintree-payment/README.md +++ b/plugins/braintree-payment/README.md @@ -116,6 +116,10 @@ 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`). - **disableVoidTransactions**: When `true`, refunds never void; only `settled`/`settling` transactions may be refunded. Late requirement so future partial order refunds and order edits can be supported (void cancels the full authorization). Default: `false`. With this enabled, `authorized`/`submitted_for_settlement` refunds fail with `INVALID_DATA` (“cannot be refunded right now because it's in status …”); other non-refundable statuses fail with `NOT_FOUND` (“cannot be refunded because it's in status …”). - **logging**: Enable verbose plugin debug logging (`true` or `false`, default: `false`). When `true`, the provider logs operation details (initiate, authorize, capture, refund, etc.) and expanded Braintree error context via Medusa's logger with a `[Braintree]` prefix. Set via `BRAINTREE_LOGGING=true` in `.env` or pass `logging: true` directly in provider options. Disable in production unless actively debugging. - **testForceSettled**: **Sandbox only.** When `true` **and** `environment` is `sandbox`, the refund flow settles the Braintree transaction via the sandbox testing API before attempting a refund. Use this to exercise the **refund** path (settled/settling) instead of the **void** path (authorized/submitted_for_settlement). Defaults to `false`. Ignored (with a warning) outside sandbox. Set via `TEST_FORCE_SETTLED=true` in `.env` wired to this option, or pass `testForceSettled: true` directly. Do not enable in production. @@ -152,6 +156,8 @@ Earlier README examples used `logging: process.env.NODE_ENV !== 'production'` (a > - `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. ### Upgrading to 0.2.5 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 95ec7612..faf43005 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'; /** Medusa DI container fields required by {@link BraintreeBase}. */ export type BraintreeConstructorArgs = Record & { @@ -462,6 +464,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; + } + /** * Creates (or reuses) the Braintree SDK gateway from plugin options. * @returns Configured {@link Braintree.BraintreeGateway} @@ -476,17 +541,24 @@ class BraintreeBase extends AbstractPaymentProvider { }; const environment = envMap[envKey] ?? Braintree.Environment.Sandbox; - const 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})`); - return gateway; + return this.gateway; } /** @@ -546,6 +618,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', + ); + } + } } /** 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 802bc467..c9bb469f 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, refundPayment never voids. Only settled/settling transactions may be refunded. * Late requirement so future partial order refunds and order edits can be supported