Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions plugins/braintree-payment/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
2 changes: 1 addition & 1 deletion plugins/braintree-payment/package.json
Original file line number Diff line number Diff line change
@@ -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",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, unknown> & {
logger: Logger;
Expand Down Expand Up @@ -256,6 +258,69 @@ class BraintreeBase extends AbstractPaymentProvider<BraintreeOptions> {
return result.data as BraintreePaymentSessionData;
}

private createHttpAgent(): http.Agent | https.Agent | undefined {
// Backward compatibility: if customHttpAgent is directly provided, use it
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

backwards compatibility? We didn't have this before right? Who would be providing options.customHttpAgent?

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;
}
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.
}

// 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);
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.

return undefined;
}

init(): void {
const envKey = (this.options_.environment || 'sandbox').toLowerCase();
const envMap: Record<string, Braintree.Environment> = {
Expand All @@ -266,15 +331,22 @@ class BraintreeBase extends AbstractPaymentProvider<BraintreeOptions> {
};
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})`);
}

Expand Down Expand Up @@ -313,6 +385,80 @@ class BraintreeBase extends AbstractPaymentProvider<BraintreeOptions> {
);
}
}

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}"`,
);
Comment thread
lcmohsen marked this conversation as resolved.
}
}

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<CapturePaymentOutput> {
Expand Down
Original file line number Diff line number Diff line change
@@ -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;
Expand All @@ -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;
}
Expand Down